Files
Genarrative/scripts/check-agc-co-creation-fork-e2e.mjs
T
suzmii 32f4025516
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
test(共创): 新增 AGC 客户端 CDP 真机 Fork 验收 + 窄屏三档核验,并同步实测覆盖文档
2026-10-07 01:27:26 +08:00

426 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏共创「客户端 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);
});