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
426 lines
17 KiB
JavaScript
426 lines
17 KiB
JavaScript
// 游戏共创「客户端 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);
|
||
});
|