test(游戏共创): 族谱 e2e 补富树 fixture(多分支多代)
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m32s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m1s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m49s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 8m7s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m54s
Project CI / Repository checks (pull_request) Successful in 6m17s
Project CI / Native shell tests (pull_request) Successful in 9m31s

This commit is contained in:
2026-10-06 14:14:44 +08:00
parent 1f85af8668
commit 35a4b5f1b0
+317 -18
View File
@@ -68,6 +68,9 @@ const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
const GATE_KEY = 'game-distribution:publish';
const SKIP_BROWSER = (process.env.SKIP_BROWSER ?? '').trim() === '1';
// H 段(富树 fixture:1 根 + 3 子 + 1 孙 + 1 曾孙,供网页端族谱可视化截图)默认执行;
// CI/快速回归可用 LINEAGE_RICH_TREE=0 关闭(它会多造 6 个真实公开作品,逐一走完整发布链路)。
const RICH_TREE = (process.env.LINEAGE_RICH_TREE ?? '1').trim() !== '0';
const LINEAGE_ENTRY_LABEL =
process.env.E2E_LINEAGE_LABEL_ENTRY ?? '查看创作族谱';
@@ -77,10 +80,13 @@ const LINEAGE_ANCHOR_LABEL = process.env.E2E_LINEAGE_LABEL_ANCHOR ?? '当前作
const LINEAGE_PARENT_UNAVAILABLE =
process.env.E2E_LINEAGE_LABEL_PARENT_UNAVAILABLE ?? '原作品已不可用';
// 详情页「改造这个作品」入口(392cb3bb0):标签与文案取自 GameDetailPage.tsx:430-490。
const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? '改造这个作品';
const REMIX_LOGIN_LABEL = process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后可改造';
const REMIX_STEPS_TEXT = process.env.E2E_REMIX_STEPS_TEXT ?? '打开陶泥儿客户端';
const REMIX_NOTE_TEXT = process.env.E2E_REMIX_NOTE_TEXT ?? '不能直接再次发布';
const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? 'Fork 这个作品';
const REMIX_LOGIN_LABEL = process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后可 Fork';
const REMIX_STEPS_TEXT =
process.env.E2E_REMIX_STEPS_TEXT ?? '从平台作品开始创作';
const REMIX_NOTE_TEXT = process.env.E2E_REMIX_NOTE_TEXT ?? '整个工程';
const REMIX_NOTE_EXTRA_TEXT =
process.env.E2E_REMIX_NOTE_EXTRA_TEXT ?? '不可撤销';
const REMIX_COPY_ID_LABEL =
process.env.E2E_REMIX_COPY_ID_LABEL ?? '复制作品 ID';
const REMIX_COPIED_ID_LABEL =
@@ -316,6 +322,30 @@ async function ownerGame(token, gameId) {
return { response, game: response.data?.game ?? null };
}
/// 取某作品「当前公开版本」的 id:改编声明的 `parentVersionId` 必须是父作品的当前公开版本。
async function publicVersionIdOf(token, gameId) {
const versions = (await ownerGame(token, gameId)).game?.versions ?? [];
return (
versions.find((version) => version.status === 'published')?.versionId ?? ''
);
}
/// 提升作品共创授权(H 段造富树时给「需要继续被 Fork」的节点用;不动既有 A 的档位)。
async function promoteForkAuthorization({
token,
gameId,
from = 'forbidden',
to = 'nonCommercial',
key,
}) {
return api(`/api/game-distribution/games/${gameId}/fork-authorization`, {
method: 'PUT',
token,
headers: { 'Idempotency-Key': key },
body: { expectedForkAuthorization: from, forkAuthorization: to },
});
}
/// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。
async function publishToPublic({
token,
@@ -410,6 +440,10 @@ async function lineageOf(gameId) {
return api(`/api/game-distribution/games/${gameId}/lineage`);
}
function publicDetail(gameId) {
return api(`/api/game-distribution/games/${gameId}`);
}
async function derivedOf(gameId) {
return api(`/api/game-distribution/games/${gameId}/derived`);
}
@@ -586,9 +620,13 @@ async function runBrowserStep({
path.join(tmpdir(), 'genarrative-lineage-e2e-'),
);
try {
const page = await browser.newPage({
// 复制反馈要真跑成功路径:headless 下不授剪贴板权限时回退路径(execCommand)会失败并显示
// 「复制失败」,那是环境限制而不是产品缺陷 —— 这里按真实用户条件授予剪贴板权限。
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
permissions: ['clipboard-read', 'clipboard-write'],
});
const page = await context.newPage();
await loginThroughWebUi(page, phone, expectedAccountText);
check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`);
@@ -668,9 +706,10 @@ async function runBrowserStep({
if (remixVisible) {
await remixEntry.click();
const panel = page.locator('#game-fork-remix-panel');
// 点击先尝试唤起客户端(自定义协议),没客户端接手才降级出面板 —— 因此给宽一点的等待。
check(
'浏览器视觉:展开后出现获取方式面板(引导去客户端粘贴)',
(await visible(panel)) &&
(await visible(panel, 15_000)) &&
(await visible(page.getByText(REMIX_STEPS_TEXT, { exact: false }))),
'panel=#game-fork-remix-panel',
);
@@ -698,9 +737,12 @@ async function runBrowserStep({
`link=${linkText}`,
);
check(
'浏览器视觉:面板给出「成品包只能作为参考且不能直接再次发布」的口径',
await visible(page.getByText(REMIX_NOTE_TEXT, { exact: false })),
`note 包含 ${REMIX_NOTE_TEXT}`,
'浏览器视觉:面板口径为「整个工程复制到你的项目里继续改造」且告知不可撤销',
(await visible(page.getByText(REMIX_NOTE_TEXT, { exact: false }))) &&
(await visible(
page.getByText(REMIX_NOTE_EXTRA_TEXT, { exact: false }),
)),
`note 需同时含 ${REMIX_NOTE_TEXT} 与 ${REMIX_NOTE_EXTRA_TEXT}`,
);
const remixShot = path.join(screenshotDir, 'game-remix-panel.png');
@@ -724,16 +766,21 @@ async function runBrowserStep({
);
if (copyIdVisible) {
await copyId.click();
const copiedShown = await visible(
page.getByRole('button', {
name: REMIX_COPIED_ID_LABEL,
exact: true,
}),
15_000,
);
const observedLabels = await page
.locator('#game-fork-remix-panel button.game-fork-remix-copy')
.evaluateAll((nodes) => nodes.map((node) => node.textContent))
.catch(() => []);
check(
'浏览器视觉:点「复制作品 ID」后按钮出现已复制反馈',
await visible(
page.getByRole('button', {
name: REMIX_COPIED_ID_LABEL,
exact: true,
}),
15_000,
),
`期望按钮名=${REMIX_COPIED_ID_LABEL}(未校验真实剪贴板内容)`,
copiedShown,
`期望按钮名=${REMIX_COPIED_ID_LABEL};实测按钮文案=${JSON.stringify(observedLabels)}(已授剪贴板权限;真实剪贴板内容仍属人工验收项)`,
);
}
}
@@ -745,7 +792,7 @@ async function runBrowserStep({
});
await page.waitForTimeout(1500);
check(
'浏览器视觉:授权 forbidden 的作品详情页没有「改造这个作品」入口',
'浏览器视觉:授权 forbidden 的作品详情页没有「Fork 这个作品」入口',
(await page
.getByRole('button', { name: REMIX_ENTRY_LABEL, exact: true })
.count()) === 0 &&
@@ -1060,6 +1107,258 @@ async function main() {
checkNodeShape(`衍生节点 ${childId}`, derivedNodes[0]);
checkNoPrivateKeys('衍生列表响应', derived);
// ---------- H 段:富树 fixture(1 根 + 3 子 + 1 孙 + 1 曾孙,供族谱可视化截图) ----------
//
// 为什么要独立一棵树:既有 A/B 那段断言里「衍生列表恰好 1 个子代」是精确断言(不动它),
// 所以富树另起一根,既不改旧断言逻辑,也能独立断言自己的形状。
// 每个节点都走真实发布链路(建作品 → 建版本 → 上传发行包 → 送审 → 管理员通过)并带真实封面;
// 血缘用 `fork: { parentGameId, parentVersionId }`(父必须是已公开且有当前公开版本)。
if (!RICH_TREE) {
skip('H) 富树 fixture(LINEAGE_RICH_TREE=0 已关闭)');
} else {
/// 造一个「已公开」节点(可选:带改编声明、可选:提升共创授权以便继续被 Fork)。
const createPublishedNode = async ({
label,
title,
owner,
ownerLabel,
parent,
promoteTo,
}) => {
const coverAssetId = await uploadCover(
owner.token,
`${stamp}-rich-${label}`,
);
const parentVersionId = parent
? await publicVersionIdOf(parent.owner.token, parent.gameId)
: '';
if (parent && !parentVersionId) {
check(
`H) 节点 ${label} 的父作品(${parent.label})有当前公开版本`,
false,
`parent=${parent.gameId}`,
);
}
const metadata = gameMetadata({ title, coverAssetId });
const created = await createGame({
token: owner.token,
metadata,
idemKey: `lineage-rich-${label}-${stamp}`,
...(parent
? { fork: { parentGameId: parent.gameId, parentVersionId } }
: {}),
});
const gameId = created.data?.id;
check(
`H) 节点 ${label}(${ownerLabel}${parent ? `,父=${parent.label}` : ',根'})创建成功`,
created.status === 200 && Boolean(gameId),
brief(created),
);
if (!gameId) return { gameId: null, label, owner, ownerLabel };
const zip = await buildZip(`rich-${label}-${stamp}`);
const published = await publishToPublic({
token: owner.token,
admin,
gameId,
gameRevision: created.data?.publicationRevision ?? 0,
metadata,
zip,
stamp,
tag: `rich-${label}`,
label: `H) 节点 ${label}`,
quiet: true,
});
const detail = await publicDetail(gameId);
check(
`H) 节点 ${label} 已公开(真实发布链路 + 真实封面)`,
published.versionId !== null && detail.data?.status === 'published',
`versionId=${published.versionId ?? '∅'} status=${detail.data?.status ?? '∅'} cover=${coverAssetId}`,
);
if (promoteTo) {
const promoted = await promoteForkAuthorization({
token: owner.token,
gameId,
key: `lineage-rich-promote-${label}-${stamp}`,
to: promoteTo,
});
check(
`H) 节点 ${label} 共创授权提升为 ${promoteTo}(供后续继续 Fork)`,
promoted.status === 200 &&
(promoted.data?.game?.forkAuthorization ?? null) === promoteTo,
brief(promoted),
);
}
return { gameId, label, owner, ownerLabel, metadata };
};
const richRoot = await createPublishedNode({
label: 'R',
title: `富树母版 ${suffix}`,
owner: author,
ownerLabel: '作者甲',
promoteTo: 'nonCommercial',
});
const richB1 = await createPublishedNode({
label: 'B1',
title: `富树分支 B1 ${suffix}`,
owner: author,
ownerLabel: '作者甲',
parent: richRoot,
promoteTo: 'nonCommercial',
});
const richC1 = await createPublishedNode({
label: 'C1',
title: `富树分支 C1 ${suffix}`,
owner: forker,
ownerLabel: '作者乙',
parent: richRoot,
promoteTo: 'full',
});
const richF1 = await createPublishedNode({
label: 'F1',
title: `富树分支 F1 ${suffix}`,
owner: forker,
ownerLabel: '作者乙',
parent: richRoot,
// 刻意保持默认 forbidden:多档位在图上可见
});
const richD = await createPublishedNode({
label: 'D',
title: `富树二代 D ${suffix}`,
owner: author,
ownerLabel: '作者甲',
parent: richB1,
promoteTo: 'nonCommercial',
});
const richE = await createPublishedNode({
label: 'E',
title: `富树三代 E ${suffix}`,
owner: forker,
ownerLabel: '作者乙',
parent: richD,
});
const richNodes = {
R: richRoot,
B1: richB1,
C1: richC1,
F1: richF1,
D: richD,
E: richE,
};
const richRootId = richRoot.gameId;
const allCreated = Object.values(richNodes).every((node) => node.gameId);
check(
'H) 富树 6 个节点全部建成(1 根 + 3 子 + 1 孙 + 1 曾孙)',
allCreated,
`ids=${Object.entries(richNodes)
.map(([key, node]) => `${key}:${node.gameId ?? '∅'}`)
.join(' ')}`,
);
if (allCreated) {
const expectedRich = [
{ key: 'R', generation: 0, parent: null, ownerLabel: '作者甲' },
{ key: 'B1', generation: 1, parent: 'R', ownerLabel: '作者甲' },
{ key: 'C1', generation: 1, parent: 'R', ownerLabel: '作者乙' },
{ key: 'F1', generation: 1, parent: 'R', ownerLabel: '作者乙' },
{ key: 'D', generation: 2, parent: 'B1', ownerLabel: '作者甲' },
{ key: 'E', generation: 3, parent: 'D', ownerLabel: '作者乙' },
];
const richTree = await lineageOf(richRootId);
check(
'H) 富树 /lineage 200 且未截断',
richTree.status === 200 && richTree.data?.truncated === false,
brief(richTree),
);
check(
'H) rootGameId 与 root 都指向富树根',
richTree.data?.rootGameId === richRootId &&
richTree.data?.root?.gameId === richRootId,
`rootGameId=${richTree.data?.rootGameId ?? ''} root=${richTree.data?.root?.gameId ?? 'null'}`,
);
const richTreeNodes = richTree.data?.nodes ?? [];
check(
'H) 树恰好含 6 个节点(不多不少)',
richTreeNodes.length === 6,
`nodes=${richTreeNodes.length} ids=${richTreeNodes.map((node) => node.gameId).join(',')}`,
);
const richById = new Map(
richTreeNodes.map((node) => [node.gameId, node]),
);
for (const expectation of expectedRich) {
const node = richById.get(richNodes[expectation.key].gameId);
const expectedParentId = expectation.parent
? richNodes[expectation.parent].gameId
: null;
check(
`H) 节点 ${expectation.key}:generation=${expectation.generation}、parentGameId=${expectation.parent ?? 'null'}`,
node?.generation === expectation.generation &&
(expectation.parent
? node?.parentGameId === expectedParentId
: node?.parentGameId == null),
`generation=${node?.generation ?? '∅'} parentGameId=${node?.parentGameId ?? 'null'}`,
);
checkNodeShape(`H) 富树节点 ${expectation.key}`, node);
const expectedAuthorName =
expectation.ownerLabel === '作者甲'
? authorDisplayName
: forkerDisplayName;
check(
`H) 节点 ${expectation.key} 作者名来自账号展示名(${expectation.ownerLabel})`,
node?.authorName === expectedAuthorName,
`authorName=${node?.authorName ?? 'null'} 期望=${expectedAuthorName}`,
);
}
checkNoPrivateKeys('H) 富树响应', richTree);
const richGenerations = richTreeNodes.map((node) => node.generation);
check(
'H) 富树按代际升序(根 → 3 个 1 代 → 2 代 → 3 代)',
richGenerations.every(
(value, index) => index === 0 || richGenerations[index - 1] <= value,
) &&
richGenerations[0] === 0 &&
richGenerations[richGenerations.length - 1] === 3,
`generations=${richGenerations.join(',')}`,
);
check(
'H) 树上同时出现两个不同作者(分支多样性在图上可见)',
new Set(richTreeNodes.map((node) => node.authorName)).size === 2,
`authors=${[...new Set(richTreeNodes.map((node) => node.authorName))].join(' / ')}`,
);
const richDerived = await derivedOf(richRootId);
const richDerivedIds = (richDerived.data?.nodes ?? [])
.map((node) => node.gameId)
.sort();
const expectedChildIds = ['B1', 'C1', 'F1']
.map((key) => richNodes[key].gameId)
.sort();
check(
'H) 富树 /derived 恰好含根的直接子(B1/C1/F1)',
richDerived.status === 200 &&
JSON.stringify(richDerivedIds) === JSON.stringify(expectedChildIds),
`derived=${richDerivedIds.join(',')} 期望=${expectedChildIds.join(',')}`,
);
const deepAnchorTree = await lineageOf(richNodes.E.gameId);
check(
'H) 最深锚点(E,第 3 代)也返回同一棵 6 节点树',
deepAnchorTree.status === 200 &&
deepAnchorTree.data?.rootGameId === richRootId &&
(deepAnchorTree.data?.nodes ?? []).length === 6,
`status=${deepAnchorTree.status} rootGameId=${deepAnchorTree.data?.rootGameId ?? ''} nodes=${(deepAnchorTree.data?.nodes ?? []).length}`,
);
console.log(
`[lineage-e2e] 富树根 id=${richRootId}(6 节点 / 3 层分支 / 1 条 3 代深链)`,
);
console.log(
`[lineage-e2e] 富树 lineage URL=${WEB}/games/lineage?id=${richRootId}`,
);
}
}
// G 段:Fork 取件通道(M2a)
// 契约(读代码确认):路由 api-server/...:307-320(Bearer + no-store,不叠发布灰度);
// 404/409/403 映射 api-server/...:2519-2560(FORK_SOURCE_NOT_FOUND / NOT_AVAILABLE / NOT_AUTHORIZED);