diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index a39b69314..ea645121d 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -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)实施证据 | 项 | 证据 | 状态 | diff --git a/package.json b/package.json index f3485bf2e..5d2aa5a58 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/capture-game-lineage-visual.mjs b/scripts/capture-game-lineage-visual.mjs index 8ad125e79..ad4eb34c7 100644 --- a/scripts/capture-game-lineage-visual.mjs +++ b/scripts/capture-game-lineage-visual.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 { diff --git a/scripts/check-agc-co-creation-fork-e2e.mjs b/scripts/check-agc-co-creation-fork-e2e.mjs new file mode 100644 index 000000000..21bd56656 --- /dev/null +++ b/scripts/check-agc-co-creation-fork-e2e.mjs @@ -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()` → `/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); +});