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
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user