From 35a4b5f1b0005beef291a64127fe02fb81cd3056 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 14:14:44 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=97=8F=E8=B0=B1=20e2e=20=E8=A1=A5=E5=AF=8C=E6=A0=91=20fixtur?= =?UTF-8?q?e=EF=BC=88=E5=A4=9A=E5=88=86=E6=94=AF=E5=A4=9A=E4=BB=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../check-game-distribution-lineage-e2e.mjs | 335 +++++++++++++++++- 1 file changed, 317 insertions(+), 18 deletions(-) diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs index f91f072df..4e64c29c2 100644 --- a/scripts/check-game-distribution-lineage-e2e.mjs +++ b/scripts/check-game-distribution-lineage-e2e.mjs @@ -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);