test(共创): 新增 AGC 客户端 CDP 真机 Fork 验收 + 窄屏三档核验,并同步实测覆盖文档
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m17s
Project CI / Backend tests (pull_request) Successful in 9m29s
Project CI / Frontend tests (pull_request) Successful in 3m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m30s
Project CI / Repository checks (pull_request) Successful in 5m28s
Project CI / Native shell tests (pull_request) Successful in 6m55s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m17s
Project CI / Backend tests (pull_request) Successful in 9m29s
Project CI / Frontend tests (pull_request) Successful in 3m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m30s
Project CI / Repository checks (pull_request) Successful in 5m28s
Project CI / Native shell tests (pull_request) Successful in 6m55s
This commit is contained in:
@@ -1008,11 +1008,20 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,
|
||||
| 收藏(收录)与「我的收藏」 | HTTP 契约 | `scripts/check-game-distribution-collection-e2e.mjs` | ✅ 已覆盖 |
|
||||
| 共创主题(公开读 + 后台写 + 作品详情 `themes` 增量 + 只允许根) | HTTP 契约 | `scripts/check-game-distribution-theme-e2e.mjs` | ✅ 已覆盖 |
|
||||
| 贡献归集(作者视角 `GET /games/{id}/contribution`) | HTTP 契约(作者 Bearer) | `scripts/check-game-distribution-lineage-e2e.mjs` I 段 | ✅ 已覆盖 |
|
||||
| **真实 AGC 客户端里点一遍**:深链 `genarrative://fork?gameId=…` 或共创列表「Fork」→ 取件校验 → 生成工程(写 `.agent/fork-source.json`)→ 客户端发布并声明 `fork` | **未自动化**(仓库内无客户端 CDP/UI 自动化)。客户端为 Tauri + WebView2,可 attach 前提是**启动时**带 `--remote-debugging-port=9222`(`~/.config/genarrative/.env` 的 `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`,仅对变量存在时**新启动**的实例生效;探活须 `curl --noproxy '*' http://127.0.0.1:9222/json/version` 应回 `Edg/…`) | 备用路径:**API 等价复现**同一链路 —— `/fork-source` 取件元数据 → 按 `downloadPath` 下载工程包/成品包 → 建项 → 首次发布携带 `fork` 声明 → 回平台断言血缘 / 代际 / 贡献 | ⏳ 待补 |
|
||||
| **真实 AGC 客户端里点一遍**:共创列表 → Fork 确认 → 取件校验 → 生成工程(写 `.agent/fork-source.json`)→ **运行可用** | **已自动化(2026-10-07)**:Tauri + WebView2 通过 CDP 附着真实客户端(客户端启动时带 `--remote-debugging-port=9222`,见 `~/.config/genarrative/.env` 的 `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`;探活 `curl --noproxy '*' http://127.0.0.1:9222/json/version` 应回 `Edg/…`) | `scripts/check-agc-co-creation-fork-e2e.mjs`(19 项,npm script `check:agc-co-creation-fork-e2e`):共创列表滚到底且无「直接 Fork 指定作品」输入块;确认页 3 秒门禁 + 须知无内层滚动 + 底部吸附条;Fork 后本机 `projects/gameagent-*` 的 `.agent/fork-source.json`(v2、`source: package`)、`game/dist/index.html`、无 `reference/`、`code-prototype=completed`、`versions=[initial-1]`;**运行视图可用**(无「首个可运行原型尚未完成」空态、运行表现层 + 预览 iframe) | ✅ 已覆盖 |
|
||||
| 客户端里**发布**新作品(上传发行包 → 送审 → 管理员审核 → 平台看到新节点/血缘) | 未自动化:客户端发布面板需要人工填「本次核心改动说明」等 | 平台侧同一段由 `scripts/check-game-distribution-lineage-e2e.mjs` 的发布链路 + 血缘断言覆盖 | ⏳ 待补 |
|
||||
| 窄屏三档(360 / 390 / 414)移动端形态 | 真实浏览器 | `scripts/capture-game-lineage-visual.mjs` 的 `E2E_CAPTURE_NARROW_WIDTHS=360,390,414`:深链进 `/games/lineage` **不再被「移动端仅支持作品展示」弹窗盖住**、默认大纲式缩进树(行高 ≥44、有导线与层级缩进)、长标题不横向溢出、点节点弹底部 sheet 且可关闭、选中后当前面包屑已被滚进视口 | ✅ 已覆盖(另见下方两条未修问题) |
|
||||
| 演示数据与视觉基线(供需求方验收) | 幂等灌数据 + 截图工具 | `scripts/seed-game-distribution-co-creation.mjs`(标题前缀 `共创演示·`,**判重不绑账号**:先查公开目录同标题再查本人 `my-games`;13 节点 / 3 棵树 / 4 作者 / 最大 3 代 / 13 张不同封面)、`scripts/capture-game-lineage-visual.mjs`(`E2E_CAPTURE_LINEAGE_URL` 开启「只截现有作品」模式) | ✅ 已覆盖 |
|
||||
|
||||
实测固定口径(可直接复用):pinned 端口 `--api-port 8188 --bgfilter-worker-port 8189 --spacetime-port 3110 --admin-web-port 3111 --web-port 3112`;管理员 `admin / 123456`;Playwright 走临时目录(`E2E_PLAYWRIGHT_DIR`)+ 本机 Chromium(`E2E_CHROMIUM_EXECUTABLE`);断言口径一律按**符号**引用实现(不写行号),详见各脚本头部注释。
|
||||
|
||||
**2026-10-07 实测发现、尚未修(`src/**` owner)**:
|
||||
|
||||
| 问题 | 证据 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 族谱画布(`layout="stacked"`)节点**不再显示作者名** | `src/components/game-distribution/LineageNodeContent.tsx` 只在 `layout === 'inline'`(详情页血缘链)渲染 `.game-lineage-node__meta`,画布节点传的是 `stacked`,于是作者名整列消失(`aria-label` 里仍有作者,故只有视觉缺失)。`check-game-distribution-lineage-e2e.mjs` → **187/188**,唯一 FAIL:`浏览器视觉:母版节点显示作者名`(期望 `族谱母版作者731982`,树上找不到该文本);1440 宽截图 `01-lineage-overview.png` 亦可见卡片只有封面 + 标题 | ⏳ 待修 |
|
||||
| 移动端切「图谱视图」(横向画布)**不会初始定位到当前作品** | 390 宽实测:切过去后 `zoom=100%`、内容层 `transform: matrix(1,0,0,1,0,0)`,画布可视区 `x 37–353`,而「当前作品」节点头部在 `x 457–633` → **0/8 个节点可见**,用户必须自己拖动画布找树(`capture-game-lineage-visual.mjs` 的窄屏三档各有 1 项 FAIL:`图谱视图(横向画布)首屏把当前作品放进画布`) | ⏳ 待产品确认 + 修 |
|
||||
|
||||
### 5.7 AGC 客户端共创页 / Fork 确认页与外壳滚动链(2026-10-07)实施证据
|
||||
|
||||
| 项 | 证据 | 状态 |
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"check:game-distribution-upload-resume": "node scripts/check-game-distribution-upload-resume.mjs",
|
||||
"check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs",
|
||||
"check:agc-update-channel-manifests": "node scripts/check-agc-update-channel-manifests.mjs",
|
||||
"check:agc-co-creation-fork-e2e": "node scripts/check-agc-co-creation-fork-e2e.mjs",
|
||||
"check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-e2e.mjs",
|
||||
"check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs",
|
||||
"check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs",
|
||||
|
||||
@@ -503,6 +503,236 @@ async function loadPlaywright() {
|
||||
return requireFromDir('playwright');
|
||||
}
|
||||
|
||||
/// 窄屏三档核验(360 / 390 / 414):移动端形态(大纲缩进树 / 图谱画布 / bottom sheet / 面包屑)、
|
||||
/// 触控尺寸与横向溢出,以及**本轮新豁免**:深链进 `/games/lineage?id=…` 首屏不再被
|
||||
/// 「移动端仅支持作品展示」欢迎弹窗盖住。
|
||||
///
|
||||
/// 契约来源(读实现确认,按符号引用):
|
||||
/// - 移动端形态与默认值:`src/components/game-distribution/GameLineagePage.tsx` 的 `viewMode` /
|
||||
/// `effectiveViewMode`(`isMobileViewport ? 'outline' : 'canvas'`)
|
||||
/// - 大纲树:`src/components/game-distribution/LineageOutlineList.tsx` 的 `lineage-outline` /
|
||||
/// `lineage-outline__row` / `lineage-outline__main` / `lineage-outline__guide`
|
||||
/// - 底部 sheet:`game-lineage-panel--sheet` + `game-lineage-sheet-backdrop`(点遮罩关闭)
|
||||
/// - 图谱视图切换:`button[aria-pressed]` 文案「图谱视图」
|
||||
/// - 面包屑:`game-lineage-breadcrumb__crumb`(`scrollIntoView` 由实现保证)
|
||||
async function captureNarrowBreakpoints({ browser, lineageUrl, shotDir }) {
|
||||
const widths = (process.env.E2E_CAPTURE_NARROW_WIDTHS ?? '')
|
||||
.split(',')
|
||||
.map((entry) => Number.parseInt(entry.trim(), 10))
|
||||
.filter((value) => Number.isInteger(value) && value >= 320);
|
||||
if (widths.length === 0) return;
|
||||
for (const width of widths) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 844 },
|
||||
hasTouch: true,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const take = async (name) => {
|
||||
const file = path.join(shotDir, `narrow-${width}-${name}.png`);
|
||||
await page.screenshot({ path: file, fullPage: false });
|
||||
console.log(`[lineage-visual] 截图 ${file}`);
|
||||
return file;
|
||||
};
|
||||
try {
|
||||
await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('.lineage-outline') !== null ||
|
||||
document.querySelector('.game-lineage-canvas') !== null,
|
||||
undefined,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const welcome = await page.evaluate(() => ({
|
||||
text: document.body.innerText.includes('移动端仅支持作品展示'),
|
||||
node: document.querySelectorAll(
|
||||
'[class*="platform-mobile-home-welcome"]',
|
||||
).length,
|
||||
}));
|
||||
check(
|
||||
`${width}px:深链进族谱页不再被「移动端仅支持作品展示」弹窗盖住`,
|
||||
!welcome.text && welcome.node === 0,
|
||||
`text=${welcome.text} nodes=${welcome.node}`,
|
||||
);
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll('.lineage-outline__row')];
|
||||
const mains = rows
|
||||
.map((row) => row.querySelector('.lineage-outline__main'))
|
||||
.filter(Boolean);
|
||||
const rects = mains.map((el) => el.getBoundingClientRect());
|
||||
const outer = document.querySelector('.lineage-outline');
|
||||
const outerRight = outer
|
||||
? outer.getBoundingClientRect().right
|
||||
: window.innerWidth;
|
||||
return {
|
||||
outline: document.querySelectorAll('.lineage-outline').length,
|
||||
rows: rows.length,
|
||||
guides: document.querySelectorAll('.lineage-outline__guide').length,
|
||||
minRowHeight: rows.length
|
||||
? Math.min(
|
||||
...rows.map((el) =>
|
||||
Math.round(el.getBoundingClientRect().height),
|
||||
),
|
||||
)
|
||||
: 0,
|
||||
firstIndent: mains.length
|
||||
? getComputedStyle(mains[0]).paddingLeft
|
||||
: '',
|
||||
lastIndent: mains.length
|
||||
? getComputedStyle(mains[mains.length - 1]).paddingLeft
|
||||
: '',
|
||||
// 缩进的真实载体是「每一代多一列导线」:比较各行内容的左边界更有意义(padding 不变)。
|
||||
firstLeft: rects.length ? Math.round(rects[0].left) : 0,
|
||||
lastLeft: rects.length ? Math.round(rects[rects.length - 1].left) : 0,
|
||||
distinctLefts: new Set(rects.map((box) => Math.round(box.left))).size,
|
||||
overflowingRows: rects.filter((box) => box.right > outerRight + 1)
|
||||
.length,
|
||||
docOverflow: document.documentElement.scrollWidth - window.innerWidth,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${width}px:默认是大纲式缩进树(有行、有导线、有缩进层级)`,
|
||||
layout.outline === 1 &&
|
||||
layout.rows >= 2 &&
|
||||
layout.guides >= 1 &&
|
||||
layout.distinctLefts >= 2 &&
|
||||
layout.lastLeft - layout.firstLeft >= 8,
|
||||
`rows=${layout.rows} guides=${layout.guides} leftOrders=${layout.firstLeft}→${layout.lastLeft} levels=${layout.distinctLefts}`,
|
||||
);
|
||||
check(
|
||||
`${width}px:大纲行高 ≥44(触控友好)且长标题不横向溢出`,
|
||||
layout.minRowHeight >= 44 &&
|
||||
layout.overflowingRows === 0 &&
|
||||
layout.docOverflow <= 1,
|
||||
`minRowHeight=${layout.minRowHeight} overflowingRows=${layout.overflowingRows} docOverflow=${layout.docOverflow}`,
|
||||
);
|
||||
await take('outline');
|
||||
|
||||
// 点第一行 → bottom sheet
|
||||
await page
|
||||
.locator('.lineage-outline__row .lineage-outline__main')
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(1200);
|
||||
const sheet = await page.evaluate(() => {
|
||||
const panel = document.querySelector('.game-lineage-panel--sheet');
|
||||
const backdrop = document.querySelector('.game-lineage-sheet-backdrop');
|
||||
if (!panel) return null;
|
||||
const box = panel.getBoundingClientRect();
|
||||
return {
|
||||
bottomGap: Math.round(window.innerHeight - box.bottom),
|
||||
height: Math.round(box.height),
|
||||
hasBackdrop: Boolean(backdrop),
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${width}px:点节点弹出底部 sheet(贴底、有遮罩)`,
|
||||
Boolean(sheet) && sheet.bottomGap <= 6 && sheet.hasBackdrop,
|
||||
JSON.stringify(sheet),
|
||||
);
|
||||
await take('sheet');
|
||||
// 关闭 sheet:先试显式的「关闭信息面板」按钮(遮罩就是它),再试 Esc。
|
||||
const closeButton = page
|
||||
.getByRole('button', { name: '关闭信息面板' })
|
||||
.first();
|
||||
let sheetClosed = false;
|
||||
if ((await closeButton.count()) > 0) {
|
||||
await closeButton.click({ force: true }).catch(() => {});
|
||||
await page.waitForTimeout(600);
|
||||
sheetClosed =
|
||||
(await page.locator('.game-lineage-panel--sheet').count()) === 0;
|
||||
}
|
||||
if (!sheetClosed) {
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
await page.waitForTimeout(400);
|
||||
sheetClosed =
|
||||
(await page.locator('.game-lineage-panel--sheet').count()) === 0;
|
||||
}
|
||||
check(`${width}px:底部 sheet 可关闭(遮罩按钮或 Esc)`, sheetClosed);
|
||||
// 画布检查前重载一次:保证不残留 sheet 与滚动位置,切形态的判断才干净。
|
||||
await page.reload({ waitUntil: 'commit', timeout: 120_000 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('.lineage-outline') !== null ||
|
||||
document.querySelector('.game-lineage-canvas') !== null,
|
||||
undefined,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
|
||||
// 切图谱视图:画布初始定位应把「当前作品」放进画布
|
||||
const toggle = page.getByText('图谱视图', { exact: true }).first();
|
||||
if ((await toggle.count()) > 0) {
|
||||
await toggle.scrollIntoViewIfNeeded().catch(() => {});
|
||||
await toggle.click({ timeout: 20000 }).catch(async () => {
|
||||
await toggle.click({ force: true, timeout: 20000 }).catch(() => {});
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
const canvas = await page.evaluate(() => {
|
||||
const canvasEl = document.querySelector('.game-lineage-canvas');
|
||||
const anchor = document.querySelector('.game-lineage-node--anchor');
|
||||
if (!canvasEl) return null;
|
||||
const canvasBox = canvasEl.getBoundingClientRect();
|
||||
const anchorBox = anchor?.getBoundingClientRect();
|
||||
return {
|
||||
anchorFound: Boolean(anchor),
|
||||
anchorInside:
|
||||
Boolean(anchorBox) &&
|
||||
anchorBox.left >= canvasBox.left - 2 &&
|
||||
anchorBox.right <= canvasBox.right + 2 &&
|
||||
anchorBox.top >= canvasBox.top - 2 &&
|
||||
anchorBox.bottom <= canvasBox.bottom + 2,
|
||||
zoom: document
|
||||
.querySelector('.game-lineage-zoom')
|
||||
?.textContent?.trim(),
|
||||
hint: document.body.innerText.includes('已按可读缩放显示'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${width}px:图谱视图(横向画布)首屏把当前作品放进画布`,
|
||||
Boolean(canvas) && canvas.anchorFound && canvas.anchorInside,
|
||||
JSON.stringify(canvas),
|
||||
);
|
||||
await take('canvas');
|
||||
|
||||
// 选中一个节点 → 面包屑;当前面包屑必须已被滚进视口
|
||||
const target = page.locator('.game-lineage-node').nth(1);
|
||||
if ((await target.count()) > 0) {
|
||||
await target.click({ position: { x: 6, y: 6 } });
|
||||
await page.waitForTimeout(1200);
|
||||
const crumb = await page.evaluate(() => {
|
||||
const items = [
|
||||
...document.querySelectorAll('.game-lineage-breadcrumb__crumb'),
|
||||
];
|
||||
if (items.length === 0) return null;
|
||||
const box = items[items.length - 1].getBoundingClientRect();
|
||||
return {
|
||||
count: items.length,
|
||||
right: Math.round(box.right),
|
||||
left: Math.round(box.left),
|
||||
innerWidth: window.innerWidth,
|
||||
insideViewport:
|
||||
box.left >= -1 && box.right <= window.innerWidth + 1,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${width}px:选中节点后面包屑出现且当前项被滚进视口`,
|
||||
Boolean(crumb) && crumb.count >= 1 && crumb.insideViewport,
|
||||
JSON.stringify(crumb),
|
||||
);
|
||||
await take('breadcrumb');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「只截图、不造树」模式:给已有作品(例如 scripts/seed-game-distribution-co-creation.mjs 灌的共创网络)
|
||||
/// 出验收截图。设 `E2E_CAPTURE_LINEAGE_URL` 即进入该模式——不注册账号、不建作品、不下架,纯 GET + 截图。
|
||||
async function captureExistingUrls() {
|
||||
@@ -639,6 +869,8 @@ async function captureExistingUrls() {
|
||||
}
|
||||
|
||||
// ---------- 窄屏 ----------
|
||||
// 移动端自 2026-10-07 起默认是**大纲式缩进树**(`lineage-outline`),不再默认渲染画布节点;
|
||||
// 这里两种形态都接受,只断言「有可交互节点 + 页面不横向溢出」。
|
||||
const mobile = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
@@ -648,13 +880,15 @@ async function captureExistingUrls() {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await mobilePage.waitForFunction(
|
||||
(min) => document.querySelectorAll('.game-lineage-node').length >= min,
|
||||
(min) =>
|
||||
document.querySelectorAll('.game-lineage-node').length >= min ||
|
||||
document.querySelectorAll('.lineage-outline__row').length >= min,
|
||||
minNodes,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await mobilePage.waitForTimeout(1400);
|
||||
// 窄屏首次进入会有「移动端仅支持作品展示」的欢迎弹窗(未 dismiss 时弹):先点掉再截图,
|
||||
// 否则截出来只剩一个遮罩弹窗,看不到窄屏族谱本身。
|
||||
// 窄屏首次进入可能弹「移动端仅支持作品展示」的欢迎弹窗(族谱深链已豁免,这里兜底):
|
||||
// 先点掉再截图,否则截出来只剩一个遮罩弹窗。
|
||||
const welcomeOk = mobilePage.getByRole('button', {
|
||||
name: '好',
|
||||
exact: true,
|
||||
@@ -666,13 +900,15 @@ async function captureExistingUrls() {
|
||||
await waitImages(mobilePage);
|
||||
const narrow = await mobilePage.evaluate(() => ({
|
||||
canvas: Boolean(document.querySelector('.game-lineage-canvas')),
|
||||
outline: document.querySelectorAll('.lineage-outline__row').length,
|
||||
nodes: document.querySelectorAll('.game-lineage-node').length,
|
||||
overflow: document.documentElement.scrollWidth - window.innerWidth > 1,
|
||||
}));
|
||||
check(
|
||||
'截图模式:窄屏下族谱画布可见、节点已渲染且页面不横向溢出',
|
||||
narrow.canvas && narrow.nodes >= minNodes && !narrow.overflow,
|
||||
`canvas=${narrow.canvas} nodes=${narrow.nodes} overflow=${narrow.overflow}`,
|
||||
'截图模式:窄屏下族谱有可交互节点(大纲或画布)且页面不横向溢出',
|
||||
(narrow.outline >= minNodes || narrow.nodes >= minNodes) &&
|
||||
!narrow.overflow,
|
||||
`outline=${narrow.outline} nodes=${narrow.nodes} canvas=${narrow.canvas} overflow=${narrow.overflow}`,
|
||||
);
|
||||
await take(mobilePage, '03-lineage-narrow.png');
|
||||
await mobile.close();
|
||||
@@ -693,6 +929,7 @@ async function captureExistingUrls() {
|
||||
void themePage;
|
||||
}
|
||||
await desktop.close();
|
||||
await captureNarrowBreakpoints({ browser, lineageUrl, shotDir });
|
||||
console.log(`[lineage-visual] 截图目录=${shotDir}`);
|
||||
console.log(`[lineage-visual] 结果:check=${checks} fail=${failures}`);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// 游戏共创「客户端 AGC 真机」链路验收:连 WebView2 的 CDP,把「共创列表 → Fork 确认 → 生成工程 → 运行可用」
|
||||
// 在**真实 AGC 客户端**里点一遍,并对本地项目做文件层断言。
|
||||
//
|
||||
// 为什么走 CDP:客户端是 Tauri + WebView2,渲染层跑在 WebView2 里;`~/.config/genarrative/.env` 里的
|
||||
// `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222` 会让**新启动**的客户端打开 CDP 端口,
|
||||
// 于是可以用 Playwright `chromium.connectOverCDP()` 接上去点真实界面(渲染层仍然是主世界,
|
||||
// `window.__TAURI__` 可直接读——这点与某些「隔离世界」驱动不同)。
|
||||
//
|
||||
// 用法:
|
||||
// E2E_PLAYWRIGHT_DIR=<临时装了 playwright 的目录> \
|
||||
// AGC_CDP_URL=http://127.0.0.1:9222 AGC_PHONE=13290000001 AGC_PASSWORD=GenE2e123! \
|
||||
// AGC_FORK_TITLE='共创演示·C1|南岭茶馆·雨前茶会' \
|
||||
// node scripts/check-agc-co-creation-fork-e2e.mjs
|
||||
//
|
||||
// 契约来源(读实现确认;按**符号**引用,不写行号):
|
||||
// - 客户端导航:`apps/ai-game-creator-shell/src/view/layout.tsx` 的 `button[aria-label="共创"]`
|
||||
// - 共创页:`src/view/co-creation/index.tsx` —— `section[aria-label="支持共创的作品"]` + 卡片里的 `Fork` 按钮;
|
||||
// 该页**没有**「直接 Fork 指定作品」输入块(深链由 launcher 直接落确认页)
|
||||
// - 确认页:`src/view/fork-confirm/index.tsx` —— `footer[aria-label="Fork 操作"]`、
|
||||
// `[aria-label="授权须知全文"]`(不折叠、**不自建内层滚动**)、`[aria-label="强制阅读进度"]`(3s 门禁)、
|
||||
// 按钮文案 `确认并进入编辑(Ns)` → `确认并进入编辑`
|
||||
// - 阶段:`src/features/platform-fork/forkProgress.ts` 的 4 段(download/verify/extract/project)
|
||||
// - 建项落盘:`src-tauri/src/game_fork.rs` 的 `create_project_from_platform_fork_at()` → `<app_data_dir>/projects/gameagent-*`;
|
||||
// `.agent/fork-source.json`(`agc-fork-source.v2`,`source: package|project`)、`.agent/manifest.json`
|
||||
// - 运行可用:`src/view/project-development/index.tsx` 的 `runAvailable` / `#run-unavailable-hint` /
|
||||
// `section[aria-label="运行表现层"]`;`src/views/runUnavailableHint.ts` 的两档文案
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const CDP_URL = (process.env.AGC_CDP_URL ?? 'http://127.0.0.1:9222').trim();
|
||||
const PHONE = (process.env.AGC_PHONE ?? '13290000001').trim();
|
||||
const PASSWORD = process.env.AGC_PASSWORD ?? 'GenE2e123!';
|
||||
const FORK_TITLE = (
|
||||
process.env.AGC_FORK_TITLE ?? '共创演示·C1|南岭茶馆·雨前茶会'
|
||||
).trim();
|
||||
const PROJECTS_ROOT = (
|
||||
process.env.AGC_PROJECTS_ROOT ??
|
||||
path.join(
|
||||
process.env.APPDATA ??
|
||||
path.join(process.env.USERPROFILE ?? '.', 'AppData/Roaming'),
|
||||
'world.genarrative.ai-game-creator',
|
||||
'projects',
|
||||
)
|
||||
).trim();
|
||||
const FORK_WAIT_TIMEOUT_MS = Number(
|
||||
process.env.AGC_FORK_TIMEOUT_MS ?? '600000',
|
||||
);
|
||||
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
const notes = [];
|
||||
function check(name, ok, detail = '') {
|
||||
checks += 1;
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
function note(text) {
|
||||
notes.push(text);
|
||||
console.log(`NOTE ${text}`);
|
||||
}
|
||||
|
||||
function listProjectDirs() {
|
||||
if (!existsSync(PROJECTS_ROOT)) return [];
|
||||
return readdirSync(PROJECTS_ROOT, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) => entry.isDirectory() && entry.name.startsWith('gameagent-'),
|
||||
)
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
async function loadPlaywright() {
|
||||
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
|
||||
if (!dir) return import('playwright');
|
||||
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
|
||||
return requireFromDir('playwright');
|
||||
}
|
||||
|
||||
/** 客户端可能停在登录页(新实例或登录态过期):用演示账号登录,返回是否发生过登录。 */
|
||||
async function ensureLoggedIn(page) {
|
||||
const loginButton = page.getByRole('button', { name: /^登录$/ }).first();
|
||||
const navCoCreation = page.locator('[aria-label="共创"]').first();
|
||||
if ((await navCoCreation.count()) > 0) return false;
|
||||
await page.getByText('密码登录', { exact: true }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('input').nth(1).fill(PHONE);
|
||||
await page.locator('input[type="password"]').first().fill(PASSWORD);
|
||||
await loginButton.click();
|
||||
await page.waitForTimeout(5000);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[agc-fork-e2e] cdp=${CDP_URL} projectsRoot=${PROJECTS_ROOT}`);
|
||||
const { chromium } = await loadPlaywright();
|
||||
const browser = await chromium.connectOverCDP(CDP_URL);
|
||||
const context = browser.contexts()[0];
|
||||
const page =
|
||||
context.pages().find((entry) => entry.url().includes('3080')) ??
|
||||
context.pages()[0];
|
||||
check(
|
||||
'已连上 AGC 客户端(CDP + WebView2 页面)',
|
||||
Boolean(page),
|
||||
page?.url() ?? '',
|
||||
);
|
||||
const shotDir = mkdtempSync(path.join(tmpdir(), 'genarrative-agc-fork-'));
|
||||
const shots = [];
|
||||
const take = async (name) => {
|
||||
const file = path.join(shotDir, name);
|
||||
await page.screenshot({ path: file, fullPage: false });
|
||||
shots.push(file);
|
||||
console.log(`[agc-fork-e2e] 截图 ${file}`);
|
||||
return file;
|
||||
};
|
||||
|
||||
const before = new Set(listProjectDirs());
|
||||
const loggedIn = await ensureLoggedIn(page);
|
||||
if (loggedIn)
|
||||
note(
|
||||
'客户端原本停在登录页:已用演示账号登录(登录态与后续点击均已在本客户端内完成)',
|
||||
);
|
||||
await page
|
||||
.locator('[aria-label="共创"]')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 120000 });
|
||||
|
||||
// ---------- 1) 共创列表 ----------
|
||||
await page.locator('[aria-label="共创"]').first().click();
|
||||
const listSection = page.locator('section[aria-label="支持共创的作品"]');
|
||||
await listSection.waitFor({ state: 'visible', timeout: 120000 });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('article').length > 0,
|
||||
undefined,
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
const listInfo = await page.evaluate(() => ({
|
||||
inputs: document.querySelectorAll('input').length,
|
||||
directForkText: document.body.innerText.includes('直接 Fork 指定作品'),
|
||||
cards: document.querySelectorAll('article').length,
|
||||
}));
|
||||
check('共创页已渲染作品卡片', listInfo.cards > 0, `cards=${listInfo.cards}`);
|
||||
check(
|
||||
'「直接 Fork 指定作品」输入块已消失(页面无输入控件、无该文案)',
|
||||
listInfo.inputs === 0 && !listInfo.directForkText,
|
||||
`inputs=${listInfo.inputs} text=${listInfo.directForkText}`,
|
||||
);
|
||||
|
||||
// 共创页的滚动容器不是 document:客户端把列表套在自己的滚动壳里,这里按「可滚动高度最大」的元素去找。
|
||||
const scrollState = await page.evaluate(() => {
|
||||
const scroller = [...document.querySelectorAll('*')]
|
||||
.filter((el) => el.scrollHeight > el.clientHeight + 20)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.scrollHeight - b.clientHeight - (a.scrollHeight - a.clientHeight),
|
||||
)[0];
|
||||
const target = scroller ?? document.scrollingElement;
|
||||
target.scrollTop = target.scrollHeight;
|
||||
return {
|
||||
tag: target.tagName,
|
||||
cls: String(target.className ?? '').slice(0, 60),
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
innerHeight: window.innerHeight,
|
||||
docHeight: document.documentElement.scrollHeight,
|
||||
};
|
||||
});
|
||||
const reachedBottom =
|
||||
scrollState.scrollTop + scrollState.clientHeight >=
|
||||
scrollState.scrollHeight - 4;
|
||||
const lastCardVisible = await page.evaluate(() => {
|
||||
const cards = [...document.querySelectorAll('article')];
|
||||
const last = cards[cards.length - 1];
|
||||
if (!last) return false;
|
||||
const box = last.getBoundingClientRect();
|
||||
return box.bottom <= window.innerHeight + 2 && box.top >= -2;
|
||||
});
|
||||
check(
|
||||
'共创列表能滚到底(滚到底后最后一张卡片完整可见)',
|
||||
reachedBottom && lastCardVisible,
|
||||
`scroller=${scrollState.tag}.${scrollState.cls} scrollTop=${scrollState.scrollTop} client=${scrollState.clientHeight} height=${scrollState.scrollHeight} lastVisible=${lastCardVisible}`,
|
||||
);
|
||||
await take('01-co-creation-list-bottom.png');
|
||||
|
||||
// ---------- 2) 打开 Fork 确认页 ----------
|
||||
const card = page.locator('article').filter({ hasText: FORK_TITLE }).first();
|
||||
check(`共创列表里能找到目标作品「${FORK_TITLE}」`, (await card.count()) > 0);
|
||||
await card
|
||||
.getByRole('button', { name: /^Fork$/ })
|
||||
.first()
|
||||
.click();
|
||||
const footer = page.locator('footer[aria-label="Fork 操作"]');
|
||||
await footer.waitFor({ state: 'visible', timeout: 60000 });
|
||||
const countdownBadge = page.locator('[aria-label="强制阅读进度"]').first();
|
||||
const confirmButton = footer
|
||||
.getByRole('button', { name: /确认并进入编辑/ })
|
||||
.first();
|
||||
check('Fork 确认页已打开(底部吸附操作条出现)', await footer.isVisible());
|
||||
const initialDisabled = await confirmButton.isDisabled();
|
||||
check(
|
||||
'强制阅读门禁生效:确认按钮初始禁用且有倒计时徽章',
|
||||
initialDisabled && (await countdownBadge.count()) > 0,
|
||||
`disabled=${initialDisabled}`,
|
||||
);
|
||||
const noticeHasInnerScroll = await page.evaluate(() => {
|
||||
const container = document.querySelector('[aria-label="授权须知全文"]');
|
||||
if (!container) return null;
|
||||
return container.scrollHeight > container.clientHeight + 1;
|
||||
});
|
||||
check(
|
||||
'授权须知全文无内层滚动(随页面自然流动)',
|
||||
noticeHasInnerScroll === false,
|
||||
`innerScroll=${noticeHasInnerScroll}`,
|
||||
);
|
||||
await take('02-fork-confirm-default.png');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const scroller = document.scrollingElement;
|
||||
scroller.scrollTo(0, scroller.scrollHeight);
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
const footerStillVisible = await footer.isVisible();
|
||||
check(
|
||||
'滚到页面底部时底部吸附操作条仍然可见',
|
||||
footerStillVisible,
|
||||
`visible=${footerStillVisible}`,
|
||||
);
|
||||
await take('03-fork-confirm-scrolled-bottom.png');
|
||||
|
||||
await confirmButton.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const button = [...document.querySelectorAll('footer button')].find(
|
||||
(entry) => (entry.textContent ?? '').includes('确认并进入编辑'),
|
||||
);
|
||||
return Boolean(button) && !button.disabled;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
check(
|
||||
'3 秒后可确认(按钮由禁用变为可用)',
|
||||
!(await confirmButton.isDisabled()),
|
||||
);
|
||||
|
||||
// ---------- 3) 确认并等待生成工程 ----------
|
||||
await confirmButton.click();
|
||||
const busySeen = await page
|
||||
.getByText('正在同步工程', { exact: false })
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 20000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
note(
|
||||
busySeen
|
||||
? '已观察到「正在同步工程…」进行中提示'
|
||||
: '未捕捉到进行中提示(同步可能很快完成)',
|
||||
);
|
||||
const stageText = await page
|
||||
.locator('[aria-label="工程同步"]')
|
||||
.first()
|
||||
.innerText()
|
||||
.catch(() => '');
|
||||
|
||||
const deadline = Date.now() + FORK_WAIT_TIMEOUT_MS;
|
||||
let newProject = null;
|
||||
while (Date.now() < deadline) {
|
||||
const now = listProjectDirs().filter((name) => !before.has(name));
|
||||
if (now.length > 0) {
|
||||
const candidate = path.join(PROJECTS_ROOT, now[0]);
|
||||
const forkRecord = path.join(candidate, '.agent', 'fork-source.json');
|
||||
const distEntry = path.join(candidate, 'game', 'dist', 'index.html');
|
||||
if (existsSync(forkRecord) && existsSync(distEntry)) {
|
||||
newProject = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
check(
|
||||
'Fork 在本机生成了新项目(projects/gameagent-*,含 .agent/fork-source.json 与预览根)',
|
||||
Boolean(newProject),
|
||||
newProject ?? `projects=${JSON.stringify(listProjectDirs().slice(-3))}`,
|
||||
);
|
||||
if (!newProject) {
|
||||
console.log(`[agc-fork-e2e] 同步状态条文本=${JSON.stringify(stageText)}`);
|
||||
await take('04-fork-failed.png');
|
||||
browser.close().catch(() => {});
|
||||
report(shotDir, shots);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[agc-fork-e2e] 新项目=${newProject}`);
|
||||
|
||||
// ---------- 4) 文件层断言 ----------
|
||||
const forkRecordPath = path.join(newProject, '.agent', 'fork-source.json');
|
||||
let forkRecord = null;
|
||||
try {
|
||||
forkRecord = JSON.parse(readFileSync(forkRecordPath, 'utf8'));
|
||||
} catch (error) {
|
||||
forkRecord = null;
|
||||
console.log(`[agc-fork-e2e] fork-source.json 解析失败:${error.message}`);
|
||||
}
|
||||
check(
|
||||
'.agent/fork-source.json 是 v2 且记录了来源作品',
|
||||
forkRecord?.schemaVersion === 'agc-fork-source.v2' &&
|
||||
typeof forkRecord?.gameId === 'string' &&
|
||||
forkRecord.gameId.length > 0,
|
||||
JSON.stringify(forkRecord),
|
||||
);
|
||||
note(
|
||||
`取件形态 source=${forkRecord?.source ?? '?'}(package=成品包,project=工程源包)`,
|
||||
);
|
||||
const distFile = path.join(newProject, 'game', 'dist', 'index.html');
|
||||
check(
|
||||
'成品包已铺进预览根(game/dist/index.html 存在,不再依赖 reference 子目录)',
|
||||
existsSync(distFile) && statSync(distFile).size > 0,
|
||||
distFile,
|
||||
);
|
||||
check(
|
||||
'没有遗留 reference/ 参考子目录',
|
||||
!existsSync(path.join(newProject, 'reference')),
|
||||
);
|
||||
const manifestPath = path.join(newProject, '.agent', 'manifest.json');
|
||||
let manifest = null;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
manifest = null;
|
||||
console.log(`[agc-fork-e2e] manifest.json 解析失败:${error.message}`);
|
||||
}
|
||||
const tasks = Array.isArray(manifest?.tasks) ? manifest.tasks : [];
|
||||
const prototypeTask = tasks.find((task) => task.id === 'code-prototype');
|
||||
const versions = Array.isArray(manifest?.versions)
|
||||
? manifest.versions.map((entry) => entry?.versionId ?? entry?.id ?? entry)
|
||||
: [];
|
||||
check(
|
||||
'清单登记了可运行原型(code-prototype=completed)',
|
||||
prototypeTask?.status === 'completed',
|
||||
JSON.stringify(prototypeTask ?? null),
|
||||
);
|
||||
check(
|
||||
'清单里是初始版本(versions 含 initial-1)',
|
||||
versions.includes('initial-1'),
|
||||
JSON.stringify(versions),
|
||||
);
|
||||
|
||||
// ---------- 5) 运行视图可用(用户 bug 的验收点) ----------
|
||||
const hint = page.locator('#run-unavailable-hint');
|
||||
const runSurface = page.locator('section[aria-label="运行表现层"]');
|
||||
await page.waitForTimeout(2500);
|
||||
const runTab = page.getByRole('tab', { name: /运行/ }).first();
|
||||
if ((await runTab.count()) > 0 && !(await runSurface.isVisible())) {
|
||||
await runTab.click().catch(() => {});
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
const hintText = (await hint.count()) > 0 ? await hint.innerText() : '';
|
||||
const prototypeHint = page.getByText('首个可运行原型尚未完成', {
|
||||
exact: false,
|
||||
});
|
||||
check(
|
||||
'运行视图不再显示「首个可运行原型尚未完成」空态',
|
||||
(await prototypeHint.count()) === 0,
|
||||
`hint=${JSON.stringify(hintText)}`,
|
||||
);
|
||||
const surfaceVisible = await runSurface.isVisible().catch(() => false);
|
||||
const frameCount = await page
|
||||
.locator('iframe[title*="游戏运行画面"]')
|
||||
.count();
|
||||
check(
|
||||
'运行视图出现运行表现层与预览画面(可运行)',
|
||||
surfaceVisible && frameCount > 0,
|
||||
`surface=${surfaceVisible} frames=${frameCount} hint=${JSON.stringify(hintText)}`,
|
||||
);
|
||||
await page.waitForTimeout(3000);
|
||||
await take('04-run-view.png');
|
||||
|
||||
// ---------- 6) 平台侧血缘(可选只读核对)----------
|
||||
const platformBase = (
|
||||
process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082'
|
||||
).replace(/\/+$/u, '');
|
||||
if (forkRecord?.gameId) {
|
||||
const response = await fetch(
|
||||
`${platformBase}/api/game-distribution/games/${forkRecord.gameId}`,
|
||||
{ headers: { 'x-genarrative-response-envelope': 'v1' } },
|
||||
).catch(() => null);
|
||||
const payload = response ? await response.json().catch(() => null) : null;
|
||||
check(
|
||||
'来源作品在平台上仍可读(Fork 前提到的是真实公开作品)',
|
||||
response?.status === 200 && payload?.data?.id === forkRecord.gameId,
|
||||
`status=${response?.status} title=${payload?.data?.title ?? '?'}`,
|
||||
);
|
||||
}
|
||||
|
||||
browser.close().catch(() => {});
|
||||
report(shotDir, shots);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
function report(shotDir, shots) {
|
||||
console.log('');
|
||||
for (const shot of shots) console.log(`[agc-fork-e2e] 截图文件 ${shot}`);
|
||||
console.log(`[agc-fork-e2e] 截图目录 ${shotDir}`);
|
||||
if (notes.length > 0) {
|
||||
console.log('[agc-fork-e2e] 备注:');
|
||||
for (const text of notes) console.log(` - ${text}`);
|
||||
}
|
||||
console.log(
|
||||
`[agc-fork-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[agc-fork-e2e] 未捕获异常:${error?.stack ?? error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user