// 游戏分发「创作族谱与衍生列表(M3)」链路真实行为验收。 // 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server [+ web])+ 管理员账号。 // // 用法(Windows + bash 均可;只依赖 Node 内置模块 + 仓库已有依赖): // E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ // node scripts/check-game-distribution-lineage-e2e.mjs // SKIP_BROWSER=1 只跑 HTTP 契约部分(跳过 Playwright 步骤) // E2E_API_BASE / E2E_WEB_BASE 覆盖地址;默认从 CWD 的 .app/dev-stack.json 读取(不写死端口) // E2E_PLAYWRIGHT_DIR 指向临时装了 playwright 的目录(仓库 devDependencies 不含 playwright) // E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器) // E2E_LINEAGE_LABEL_* 覆盖前端文案(默认对齐实现里的字符串) // // 参考的仓库既有实现(按**符号**引用,不写行号,避免随改动腐化): // [1] 路由挂载(公开、匿名、no-store):server-rs/crates/api-server/src/modules/game_distribution.rs 的 // `router()` 里 `public_games` 子路由 // [2] 「不可读即 404」映射:api-server/src/modules/game_distribution.rs 的 `lineage_read_or_not_found()` // [3] 两个 handler:api-server/src/modules/game_distribution.rs 的 `get_game_lineage()` / `get_game_derived_games()` // [4] 节点 DTO 的 7 个键:server-rs/crates/shared-contracts/src/game_distribution.rs 的 `GameDistributionLineageNode`; // 两个响应:同文件的 `GameDistributionLineageResponse` / `GameDistributionDerivedResponse` // [5] 上限 200 / 锚点可读性 / 稳定排序:server-rs/crates/module-game-distribution/src/lineage.rs 的 // `GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT` / `lineage_anchor_readable()` / `stable_node_order()` // [6] 作者软删除:api-server/src/modules/game_distribution.rs 的 `delete_owner_game()`; // 事务语义(撤销当前公开版本 + 写 deleted_at): // server-rs/crates/spacetime-module/src/game_distribution.rs 的 `delete_game_distribution_game_tx()` // [7] 改编声明 DTO:shared-contracts/src/game_distribution.rs 的 `GameDistributionForkDeclaration`; // 消费点 api-server/src/modules/game_distribution.rs 的 `create_game()`; // 事务校验 spacetime-module/src/game_distribution.rs 的 `resolve_game_distribution_fork_declaration_tx()` // [8] 发布链路(建版本 → 传包 → 送审 → 管理员通过)照抄 scripts/check-game-distribution-media-e2e.mjs 的 `main()` // [9] 封面直传 + 确认:scripts/check-game-distribution-owner-isolation.mjs 的 `uploadCover()` / `main()` // [10] 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-ratings-e2e.mjs 模块顶部的 dev-stack 读取 // [11] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs 的 // `loadPlaywright()` / `loginThroughWebUi()` // [12] 前端契约:详情页入口 src/components/game-distribution/GameDetailPage.tsx 的 `GameDetailContent` // (共创信息卡里的「查看创作族谱」按钮);族谱页 src/components/game-distribution/GameLineagePage.tsx 的 // `LineageNodeCard`(节点卡片类名、「原作品已不可用」、标题按钮);路由 /games/lineage: // src/routing/activeAppPageRoutes.ts 的 `STAGE_ROUTE_ENTRIES` // [13] 节点作者名读时联账号表:spacetime-module/src/game_distribution.rs 的 `game_distribution_lineage_author_name()` // (所以脚本会先给作者改名,让「删除后不外发作者名」这条断言有意义) // // 依赖提示:改编声明 `fork: { parentGameId, parentVersionId }` 的消费链路**已在 M2a 落地** // (shared-contracts 的 DTO → api-server `create_game()` → spacetime-module // `resolve_game_distribution_fork_declaration_tx()`),本脚本按已落地的形状断言;如果 B/C 段 // 因创建 B 时声明不生效而失败,那说明对应实现被回退了,不是脚本问题。 // // 契约假设(读代码确认,不猜): // - anchor 必须公开可读,否则 /lineage 与 /derived 都返回 **404**(不是 200 空树、也不是 409)。 // - 节点只含「已公开且未软删除」的作品;根被删时 `root` 为 null,但 `rootGameId` 仍回传。 // - 节点键集合恰好 7 个:gameId / title / authorName / generation / parentGameId / playCount / status。 // - 排序:代际升序 → 创建时间升序 → gameId 升序(本脚本只断言代际升序这一段)。 // - 上限 200 且超限时 `truncated: true`;本脚本不构造 >200 真实节点,只把常量钉在源码上(见 E 段说明)。 import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { mkdtemp } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; import JSZip from 'jszip'; const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); 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'; const LINEAGE_ENTRY_LABEL = process.env.E2E_LINEAGE_LABEL_ENTRY ?? '查看创作族谱'; const LINEAGE_PAGE_LABEL = process.env.E2E_LINEAGE_LABEL_PAGE ?? '创作族谱'; const LINEAGE_ROOT_LABEL = process.env.E2E_LINEAGE_LABEL_ROOT ?? '母版'; 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_COPY_ID_LABEL = process.env.E2E_REMIX_COPY_ID_LABEL ?? '复制作品 ID'; const REMIX_COPIED_ID_LABEL = process.env.E2E_REMIX_COPIED_ID_LABEL ?? '作品 ID 已复制'; // 节点键集合由实现钉死(shared-contracts/src/game_distribution.rs:262-281),多一个键就失败。 const LINEAGE_NODE_KEYS = [ 'authorName', 'gameId', 'generation', 'parentGameId', 'playCount', 'status', 'title', ]; // 这 7 个键之外不该出现的东西:对象键 / 素材键 / 包摘要等私有字段。 const PRIVATE_KEY_PATTERN = /objectKey|assetId|coverObjectKey|screenshots|packageSha|sha256|bucket|oss|downloadPath/i; // 取件元数据里 sha256 / bytes / downloadPath 本身是契约字段,所以这里只查「对象键 / 存储域名」。 const FORK_SOURCE_LEAK_PATTERN = /objectKey|coverObjectKey|bucket|aliyuncs|oss-cn|\.zip/iu; if (!ADMIN_USER || !ADMIN_PASSWORD) { console.error( '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' + 'game-distribution:publish 写入口并走完整发布链路;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' + 'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。', ); process.exit(2); } const devStack = JSON.parse( readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'), ); const API = ( process.env.E2E_API_BASE ?? devStack.services?.['api-server']?.url ?? '' ).replace(/\/+$/u, ''); const WEB = ( process.env.E2E_WEB_BASE ?? devStack.services?.web?.url ?? 'http://127.0.0.1:3000' ).replace(/\/+$/u, ''); if (!API) { console.error( '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' + '或用 E2E_API_BASE 显式指定。', ); process.exit(2); } let checks = 0; let failures = 0; let skipped = 0; function check(name, ok, detail = '') { checks += 1; if (!ok) failures += 1; console.log( `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, ); } function skip(name, detail = '') { skipped += 1; console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`); } // 不计入断言、但必须让读者看到的信息(例如按成本降级的覆盖项)。 function note(message) { console.log(`NOTE ${message}`); } // ---------- HTTP helpers(对齐 check-game-distribution-fork-authorization-e2e.mjs) ---------- async function api(pathname, options = {}) { const { method = 'GET', token, body, headers = {}, binary } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; finalBody = JSON.stringify(body); } const response = await fetch(`${API}${pathname}`, { method, headers: finalHeaders, body: finalBody, signal: AbortSignal.timeout(30_000), }); const text = await response.text(); let json = null; try { json = JSON.parse(text); } catch { json = null; } return { status: response.status, text, json, data: json?.data, error: json?.error, cacheControl: response.headers.get('cache-control') ?? '', }; } function brief(body) { const code = body?.error?.code ?? body?.json?.error?.code ?? ''; return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 200)}`; } async function register(prefix) { const phone = `${prefix}${String(Date.now()).slice(-8)}`; const response = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: phone, password: DEV_PASSWORD }, }); return { phone, response, token: response.data?.token }; } // 作者名读时联账号表(spacetime-module/src/game_distribution.rs:4294-4308), // 所以先改名,C 段的「删除后不外发作者名」才有实际含义。 async function setDisplayName(token, displayName) { return api('/api/profile/me', { method: 'PATCH', token, body: { displayName }, }); } function gameMetadata({ title, coverAssetId }) { return { title, summary: '创作族谱验收临时作品', description: '', category: '休闲', tags: ['e2e'], coverAssetId, deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', }; } // 封面直传 + 确认(对齐 owner-isolation.mjs:97-150;只往 dev bucket 写一个 67 字节 PNG)。 async function uploadCover(token, id) { const fileName = `lineage-${id}.png`; const ticket = await api('/api/assets/direct-upload-tickets', { method: 'POST', token, body: { legacyPrefix: 'generated-character-drafts', pathSegments: ['game-distribution', 'lineage', String(id)], fileName, contentType: 'image/png', access: 'private', maxSizeBytes: COVER_PNG.length, metadata: { asset_kind: 'game_distribution_cover' }, }, }); if (ticket.status !== 200) { throw new Error( `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, ); } const upload = ticket.data.upload; const form = new FormData(); for (const [key, value] of Object.entries(upload.formFields ?? {})) { if (value !== null && value !== undefined) form.append(key, String(value)); } form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName); const put = await fetch(upload.host, { method: 'POST', body: form }); if (!put.ok) { throw new Error(`直传对象存储失败 ${put.status}`); } const confirm = await api('/api/assets/objects/confirm', { method: 'POST', token, body: { bucket: upload.bucket, objectKey: upload.objectKey, contentType: 'image/png', contentLength: COVER_PNG.length, assetKind: 'game_distribution_cover', accessPolicy: 'private', entityId: 'game-distribution-lineage', }, }); if (confirm.status !== 200) { throw new Error( `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, ); } return confirm.data.assetObject.assetObjectId; } // 最小合法发行包(对齐 media-e2e.mjs:201-215 的 fixture 形态:index.html + 一个资源)。 async function buildZip(marker) { const zip = new JSZip(); zip.file( 'index.html', `lineage ${marker}` + '

' + marker + '

', ); zip.file( 'assets/app.js', `document.documentElement.dataset.marker='${marker}';`, ); const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); return { bytes, fileCount: 2, sha256: createHash('sha256').update(bytes).digest('hex'), }; } async function createGame({ token, metadata, idemKey, fork }) { return api('/api/game-distribution/games', { method: 'POST', token, headers: { 'Idempotency-Key': idemKey }, body: fork ? { ...metadata, fork } : metadata, }); } async function ownerGame(token, gameId) { const response = await api(`/api/game-distribution/my-games/${gameId}`, { token, }); return { response, game: response.data?.game ?? null }; } /// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。 async function publishToPublic({ token, admin, gameId, gameRevision, metadata, zip, stamp, tag, label, }) { const created = await api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token, headers: { 'Idempotency-Key': `lineage-version-${tag}-${stamp}` }, body: { packageSha256: zip.sha256, packageBytes: zip.bytes.length, packageFileCount: zip.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }); const versionId = created.data?.versionId; check( `${label} 创建版本成功`, created.status === 200 && Boolean(versionId), `status=${created.status} ${created.text.slice(0, 160)}`, ); if (!versionId) return { versionId: null }; const upload = await api( `/api/game-distribution/versions/${versionId}/package`, { method: 'PUT', token, headers: { 'Idempotency-Key': `lineage-upload-${tag}-${stamp}`, 'Content-Type': 'application/zip', }, binary: zip.bytes, }, ); check( `${label} 上传发行包成功`, upload.status === 200 && upload.data?.status === 'uploaded', `status=${upload.status}`, ); const submitted = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token, headers: { 'Idempotency-Key': `lineage-submit-${tag}-${stamp}` }, body: { expectedPublicationRevision: gameRevision }, }, ); check( `${label} 送审成功(202 + pending_review)`, submitted.status === 202 && submitted.data?.version?.status === 'pending_review', `status=${submitted.status} ${submitted.text.slice(0, 160)}`, ); const readback = await api(`/api/game-distribution/versions/${versionId}`, { token, }); const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `lineage-approve-${tag}-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: readback.data?.version?.publicationRevision ?? gameRevision, }, }, ); check( `${label} 管理员审核通过并公开`, approved.status === 200, `status=${approved.status} ${approved.text.slice(0, 160)}`, ); return { versionId, approved }; } async function lineageOf(gameId) { return api(`/api/game-distribution/games/${gameId}/lineage`); } async function derivedOf(gameId) { return api(`/api/game-distribution/games/${gameId}/derived`); } /// 取件包必须按字节校验,不能走读文本的 api()。 async function downloadBinary(pathname, token) { const headers = { ...ENVELOPE }; if (token) headers.Authorization = `Bearer ${token}`; const response = await fetch(`${API}${pathname}`, { headers, signal: AbortSignal.timeout(60_000), }); const bytes = Buffer.from(await response.arrayBuffer()); return { status: response.status, contentType: response.headers.get('content-type') ?? '', contentLength: response.headers.get('content-length') ?? '', cacheControl: response.headers.get('cache-control') ?? '', bytes, }; } function checkNodeShape(label, node) { const keys = Object.keys(node ?? {}).sort(); const expected = [...LINEAGE_NODE_KEYS].sort(); check( `${label}(节点键集合恰好 ${LINEAGE_NODE_KEYS.length} 个)`, JSON.stringify(keys) === JSON.stringify(expected), `keys=${keys.join(',')}`, ); } function checkNoPrivateKeys(label, response) { check( `${label}(不下发对象键/素材键/包摘要)`, !PRIVATE_KEY_PATTERN.test(String(response?.text ?? '')), 'pattern=objectKey|assetId|coverObjectKey|screenshots|packageSha|sha256|bucket|oss|downloadPath', ); } /// 锚点不可读(未公开 / 已软删除 / 不存在)时,两个读接口都必须 404,且不能是成功信封空树。 async function checkAnchorNotReadable(label, gameId) { const lineage = await lineageOf(gameId); check(`${label} /lineage → 404`, lineage.status === 404, brief(lineage)); check( `${label} /lineage 404 不是成功信封空树`, lineage.json?.ok !== true && lineage.data == null, `ok=${String(lineage.json?.ok)} data=${JSON.stringify(lineage.data ?? null).slice(0, 80)}`, ); const derived = await derivedOf(gameId); check(`${label} /derived → 404`, derived.status === 404, brief(derived)); check( `${label} /derived 404 不是成功信封空树`, derived.json?.ok !== true && derived.data == null, `ok=${String(derived.json?.ok)} data=${JSON.stringify(derived.data ?? null).slice(0, 80)}`, ); } // ---------- Playwright helpers(对齐 web-publish-e2e.mjs:51-90,148-159) ---------- 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 loginThroughWebUi(page, phone, expectedText) { await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); await page.waitForSelector('.platform-account-entry', { timeout: 120_000 }); await page.locator('.platform-account-entry').first().click(); await page.getByRole('tab', { name: '密码登录' }).first().click(); await page.waitForTimeout(600); await page .getByLabel('同意法律协议') .first() .check({ force: true }) .catch(() => {}); await page .locator('input[placeholder="13800000000"]:visible') .first() .fill(phone); await page .locator('input[placeholder="输入密码"]:visible') .first() .fill(DEV_PASSWORD); await page.getByRole('button', { name: '登录', exact: true }).first().click(); // 账号入口显示的是 displayName + publicUserCode(PlatformEntryActiveFlowShell.tsx:806-810), // 不保证含手机号后四位,因此以「登录页签消失 + 入口出现」为成功判据,再按需校验展示名。 await page .getByRole('tab', { name: '密码登录' }) .first() .waitFor({ state: 'hidden', timeout: 30_000 }) .catch(() => {}); await page .locator('.platform-account-entry') .first() .waitFor({ state: 'visible', timeout: 30_000 }); if (expectedText) { await page .locator('.platform-account-entry') .first() .filter({ hasText: expectedText }) .waitFor({ state: 'visible', timeout: 30_000 }) .catch(() => {}); } } async function visible(locator, timeout = 30_000) { return locator .first() .waitFor({ state: 'visible', timeout }) .then(() => true) .catch(() => false); } // ---------- 浏览器步骤 ---------- async function runBrowserStep({ phone, expectedAccountText, childId, childTitle, parentId, parentTitle, parentAuthorName, forbiddenGameId, deleteParent, }) { if (SKIP_BROWSER) { skip( '浏览器视觉:/games/detail 族谱入口与 /games/lineage 树渲染', 'SKIP_BROWSER=1', ); return; } let chromium; try { ({ chromium } = await loadPlaywright()); } catch (error) { check( '浏览器视觉:Playwright 可用', false, `仓库 devDependencies 不含 playwright(package.json),请先执行 ` + `\`npm install --prefix %TEMP%\\genarrative-pw --no-save --no-package-lock playwright\` ` + `并设置 E2E_PLAYWRIGHT_DIR=%TEMP%\\genarrative-pw;或设置 SKIP_BROWSER=1 只跑 HTTP 部分。` + `原始错误:${error?.message ?? error}`, ); return; } const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); const browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}), }); const screenshotDir = await mkdtemp( path.join(tmpdir(), 'genarrative-lineage-e2e-'), ); try { const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, }); await loginThroughWebUi(page, phone, expectedAccountText); check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`); await page.goto(`${WEB}/games/detail?id=${childId}`, { waitUntil: 'commit', timeout: 120_000, }); const entry = page.getByRole('button', { name: LINEAGE_ENTRY_LABEL, exact: true, }); const entryVisible = await visible(entry, 60_000); check( '浏览器视觉:已公开作品的详情页出现「查看创作族谱」入口', entryVisible, `child=${childId} label=${LINEAGE_ENTRY_LABEL}`, ); if (entryVisible) { await entry.click(); await page.waitForTimeout(800); check( '浏览器视觉:进入 /games/lineage 且 id=根作品', page.url().includes('/games/lineage') && page.url().includes(`id=${parentId}`), `url=${page.url()} rootId=${parentId}`, ); check( '浏览器视觉:族谱页标题可见', await visible(page.getByText(LINEAGE_PAGE_LABEL)), `label=${LINEAGE_PAGE_LABEL}`, ); check( '浏览器视觉:母版节点与当前作品节点同时可见', (await visible(page.getByText(LINEAGE_ROOT_LABEL))) && (await visible(page.getByText(LINEAGE_ANCHOR_LABEL))), `root=${LINEAGE_ROOT_LABEL} anchor=${LINEAGE_ANCHOR_LABEL}`, ); check( '浏览器视觉:树上能看到母版与当前作品标题', (await visible(page.getByText(parentTitle, { exact: false }))) && (await visible(page.getByText(childTitle, { exact: false }))), `parent=${parentTitle} child=${childTitle}`, ); if (parentAuthorName) { check( '浏览器视觉:母版节点显示作者名', await visible(page.getByText(parentAuthorName, { exact: false })), `author=${parentAuthorName}`, ); } const beforeShot = path.join( screenshotDir, 'lineage-tree-before-delete.png', ); await page.screenshot({ path: beforeShot, fullPage: true }); check('浏览器视觉:删除前族谱已截图', true, `截图路径=${beforeShot}`); const lineageUrl = page.url(); // 详情页「改造这个作品」(392cb3bb0):触发条件 = 已公开 + 授权非禁止(GameDetailPage.tsx:350)。 await page.goto(`${WEB}/games/detail?id=${parentId}`, { waitUntil: 'commit', timeout: 120_000, }); const remixEntry = page.getByRole('button', { name: REMIX_ENTRY_LABEL, exact: true, }); const remixVisible = await visible(remixEntry, 60_000); check( '浏览器视觉:已公开且授权开放的母版详情页出现「改造这个作品」入口', remixVisible, `parent=${parentId} label=${REMIX_ENTRY_LABEL}`, ); if (remixVisible) { await remixEntry.click(); const panel = page.locator('#game-fork-remix-panel'); check( '浏览器视觉:展开后出现获取方式面板(引导去客户端粘贴)', (await visible(panel)) && (await visible(page.getByText(REMIX_STEPS_TEXT, { exact: false }))), 'panel=#game-fork-remix-panel', ); const codes = panel.locator('.game-fork-remix-value code'); const idText = ( await codes .nth(0) .innerText() .catch(() => '') ).trim(); const linkText = ( await codes .nth(1) .innerText() .catch(() => '') ).trim(); check( '浏览器视觉:面板显示的作品 ID 等于母版', idText === parentId, `id=${idText} 期望=${parentId}`, ); check( '浏览器视觉:面板显示的可复制作品链接指向该作品', linkText.includes(`/games/detail?id=${parentId}`), `link=${linkText}`, ); check( '浏览器视觉:面板给出「成品包只能作为参考且不能直接再次发布」的口径', await visible(page.getByText(REMIX_NOTE_TEXT, { exact: false })), `note 包含 ${REMIX_NOTE_TEXT}`, ); const remixShot = path.join(screenshotDir, 'game-remix-panel.png'); await page.screenshot({ path: remixShot, fullPage: true }); check( '浏览器视觉:改造入口展开态已截图', true, `截图路径=${remixShot}`, ); // 复制反馈只看 UI(读系统剪贴板需要额外权限,属人工验收项)。 const copyId = page.getByRole('button', { name: REMIX_COPY_ID_LABEL, exact: true, }); const copyIdVisible = await visible(copyId, 15_000); check( '浏览器视觉:复制作品 ID 按钮可见', copyIdVisible, `label=${REMIX_COPY_ID_LABEL}`, ); if (copyIdVisible) { await copyId.click(); check( '浏览器视觉:点「复制作品 ID」后按钮出现已复制反馈', await visible( page.getByRole('button', { name: REMIX_COPIED_ID_LABEL, exact: true, }), 15_000, ), `期望按钮名=${REMIX_COPIED_ID_LABEL}(未校验真实剪贴板内容)`, ); } } // 对照:已公开但授权仍为 forbidden 的作品不得出现该入口(也不得出现未登录态文案)。 await page.goto(`${WEB}/games/detail?id=${forbiddenGameId}`, { waitUntil: 'commit', timeout: 120_000, }); await page.waitForTimeout(1500); check( '浏览器视觉:授权 forbidden 的作品详情页没有「改造这个作品」入口', (await page .getByRole('button', { name: REMIX_ENTRY_LABEL, exact: true }) .count()) === 0 && (await page .getByRole('button', { name: REMIX_LOGIN_LABEL, exact: true }) .count()) === 0, `forbidden=${forbiddenGameId}`, ); // 父作品软删除后:树仍在,但父节点内容不得再出现。 const deleted = await deleteParent(); check( '(前置)浏览器阶段完成父作品软删除', deleted?.status === 200, brief(deleted), ); await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 }); check( '浏览器视觉:父作品不可用后族谱页显示降级文案', await visible(page.getByText(LINEAGE_PARENT_UNAVAILABLE), 60_000), `label=${LINEAGE_PARENT_UNAVAILABLE}`, ); check( '浏览器视觉:父作品标题不再出现在族谱页', (await page.getByText(parentTitle, { exact: false }).count()) === 0, `parent=${parentTitle}`, ); const afterShot = path.join( screenshotDir, 'lineage-tree-after-delete.png', ); await page.screenshot({ path: afterShot, fullPage: true }); check('浏览器视觉:删除后族谱已截图', true, `截图路径=${afterShot}`); } } finally { await browser.close().catch(() => {}); console.log(`[lineage-e2e] 截图目录:${screenshotDir}`); } } // ---------- 主流程 ---------- async function main() { console.log( `[lineage-e2e] api-server=${API} web=${WEB} database=${devStack.database}`, ); const adminLogin = await api('/admin/api/login', { method: 'POST', body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, }); const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; check( '管理员登录成功', adminLogin.status === 200 && Boolean(admin), `status=${adminLogin.status}`, ); if (!admin) process.exit(1); const gate = await api('/admin/api/feature-gates', { method: 'PUT', token: admin, body: { gateKey: GATE_KEY, enabled: true, rolloutPercent: 100, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 创作族谱链路', }, }); check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); const stamp = Date.now(); const suffix = String(stamp).slice(-6); const author = await register('132'); const forker = await register('133'); check( '母版作者注册拿到 token', author.response.status === 200 && Boolean(author.token), `status=${author.response.status} phone=${author.phone}`, ); check( '改编作者注册拿到 token', forker.response.status === 200 && Boolean(forker.token), `status=${forker.response.status} phone=${forker.phone}`, ); if (!author.token || !forker.token) process.exit(1); const authorDisplayName = `族谱母版作者${suffix}`; const forkerDisplayName = `族谱改编作者${suffix}`; const renameAuthor = await setDisplayName(author.token, authorDisplayName); const renameForker = await setDisplayName(forker.token, forkerDisplayName); check( '两名作者都已设置展示名(让作者名相关断言有意义)', renameAuthor.status === 200 && renameForker.status === 200, `author=${renameAuthor.status} forker=${renameForker.status}`, ); // A 段:404 规则(防泄露回归网) const parentTitle = `族谱母版 ${suffix}`; const childTitle = `族谱改编 ${suffix}`; const blockedTitle = `族谱禁改编 ${suffix}`; const parentCover = await uploadCover(author.token, `${stamp}-parent`); const parentCreated = await createGame({ token: author.token, metadata: gameMetadata({ title: parentTitle, coverAssetId: parentCover }), idemKey: `lineage-parent-${stamp}`, }); const parentId = parentCreated.data?.id; check( '母版作品(草稿)创建成功', parentCreated.status === 200 && Boolean(parentId), `status=${parentCreated.status} id=${parentId ?? ''} ${parentCreated.text.slice(0, 120)}`, ); const parentRevision = parentCreated.data?.publicationRevision ?? 0; if (!parentId) process.exit(1); await checkAnchorNotReadable('未公开草稿锚点', parentId); await checkAnchorNotReadable('不存在的 gameId', `game_${'0'.repeat(32)}`); const lineageNoStore = await lineageOf(parentId); const derivedNoStore = await derivedOf(parentId); check( '两个读接口都返回 no-store(404 也带)', lineageNoStore.cacheControl.includes('no-store') && derivedNoStore.cacheControl.includes('no-store'), `lineage=${lineageNoStore.cacheControl} derived=${derivedNoStore.cacheControl}`, ); // B 段:把母版推到公开 const parentZip = await buildZip(`parent-${suffix}`); const parentMetadata = gameMetadata({ title: parentTitle, coverAssetId: parentCover, }); await publishToPublic({ token: author.token, admin, gameId: parentId, gameRevision: parentRevision, metadata: parentMetadata, zip: parentZip, stamp, tag: 'parent', label: '母版', }); const parentPublicDetail = await api( `/api/game-distribution/games/${parentId}`, ); check( '母版已公开可读(公开详情 200 + status=published)', parentPublicDetail.status === 200 && parentPublicDetail.data?.status === 'published', `status=${parentPublicDetail.status} visibility=${parentPublicDetail.data?.status ?? ''}`, ); // 授权阶梯:改编需要来源作品允许共创 const promote = await api( `/api/game-distribution/games/${parentId}/fork-authorization`, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `lineage-promote-${stamp}` }, body: { expectedForkAuthorization: 'forbidden', forkAuthorization: 'nonCommercial', }, }, ); check( '母版共创授权提升为 nonCommercial', promote.status === 200 && (promote.data?.game?.forkAuthorization ?? null) === 'nonCommercial', brief(promote), ); const parentOwnerView = await ownerGame(author.token, parentId); const parentVersions = parentOwnerView.game?.versions ?? []; const parentPublicVersion = parentVersions.find((version) => version.status === 'published') ?? null; const parentPublicVersionId = parentPublicVersion?.versionId ?? ''; check( '取到母版当前公开版本 id(改编声明需要)', Boolean(parentPublicVersionId), `versions=${parentVersions.map((version) => `${version.versionNumber}:${version.status}`).join(',')}`, ); if (!parentPublicVersionId) process.exit(1); // 创建携带改编声明的子作品(依赖 M2a fork 声明链路) const childCover = await uploadCover(forker.token, `${stamp}-child`); const childMetadata = gameMetadata({ title: childTitle, coverAssetId: childCover, }); const childCreated = await createGame({ token: forker.token, metadata: childMetadata, idemKey: `lineage-child-${stamp}`, fork: { parentGameId: parentId, parentVersionId: parentPublicVersionId, }, }); const childId = childCreated.data?.id; check( '携带改编声明的子作品创建成功', childCreated.status === 200 && Boolean(childId), `status=${childCreated.status} id=${childId ?? ''} ${childCreated.text.slice(0, 200)}`, ); const childRevision = childCreated.data?.publicationRevision ?? 0; if (!childId) process.exit(1); const childZip = await buildZip(`child-${suffix}`); await publishToPublic({ token: forker.token, admin, gameId: childId, gameRevision: childRevision, metadata: childMetadata, zip: childZip, stamp, tag: 'child', label: '子作品', }); // C 段:真实树形状(锚点 = 子作品) const tree = await lineageOf(childId); check('族谱接口 200', tree.status === 200, brief(tree)); const treeData = tree.data ?? {}; check( 'rootGameId 指向母版', treeData.rootGameId === parentId, `rootGameId=${treeData.rootGameId ?? ''} 期望=${parentId}`, ); check( 'root 指向母版节点', treeData.root?.gameId === parentId, `root=${treeData.root?.gameId ?? 'null'}`, ); check( '小树未截断(truncated=false)', treeData.truncated === false, `truncated=${String(treeData.truncated)}`, ); const treeNodes = treeData.nodes ?? []; const nodeIds = treeNodes.map((node) => node.gameId); check( 'nodes 同时含母版与子作品', nodeIds.includes(parentId) && nodeIds.includes(childId), `nodes=${nodeIds.join(',')}`, ); const parentNode = treeNodes.find((node) => node.gameId === parentId); const childNode = treeNodes.find((node) => node.gameId === childId); check( '母版 generation=0 且无父', parentNode?.generation === 0 && parentNode?.parentGameId == null, `generation=${parentNode?.generation} parentGameId=${parentNode?.parentGameId ?? 'null'}`, ); check( '子作品 generation=1 且 parentGameId=母版', childNode?.generation === 1 && childNode?.parentGameId === parentId, `generation=${childNode?.generation} parentGameId=${childNode?.parentGameId ?? 'null'}`, ); check( '两个节点都标为 published', parentNode?.status === 'published' && childNode?.status === 'published', `parent=${parentNode?.status} child=${childNode?.status}`, ); check( '母版节点作者名读取自账号展示名', parentNode?.authorName === authorDisplayName, `authorName=${parentNode?.authorName ?? 'null'} 期望=${authorDisplayName}`, ); const generations = treeNodes.map((node) => node.generation); check( '节点按代际升序(母版在前)', generations.every( (value, index) => index === 0 || generations[index - 1] <= value, ) && nodeIds.indexOf(parentId) < nodeIds.indexOf(childId), `generations=${generations.join(',')}`, ); checkNodeShape(`族谱节点 ${parentId}`, parentNode); checkNodeShape(`族谱节点 ${childId}`, childNode); checkNoPrivateKeys('族谱响应', tree); // 锚点换成母版:同一棵树 const treeFromParent = await lineageOf(parentId); check( '锚点换成母版仍返回同一棵树', treeFromParent.status === 200 && treeFromParent.data?.rootGameId === parentId && (treeFromParent.data?.nodes ?? []).length === treeNodes.length, `status=${treeFromParent.status} nodes=${(treeFromParent.data?.nodes ?? []).length}`, ); // 衍生列表(锚点 = 母版):恰好一个直接子代 const derived = await derivedOf(parentId); check('衍生列表接口 200', derived.status === 200, brief(derived)); const derivedNodes = derived.data?.nodes ?? []; check( '衍生列表恰好含子作品', derivedNodes.length === 1 && derivedNodes[0]?.gameId === childId, `nodes=${derivedNodes.map((node) => node.gameId).join(',')}`, ); check( '衍生列表未截断且 gameId 为母版', derived.data?.truncated === false && derived.data?.gameId === parentId, `truncated=${String(derived.data?.truncated)} gameId=${derived.data?.gameId ?? ''}`, ); checkNodeShape(`衍生节点 ${childId}`, derivedNodes[0]); checkNoPrivateKeys('衍生列表响应', derived); // 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); // 元数据载荷 api-server/...:2586-2604(同源相对 downloadPath,绝不下发对象键); // 整包响应 api-server/...:2633-2665(application/zip + content-length + 附件名 + no-store)。 const forkSourceOf = (gameId, token) => api(`/api/game-distribution/games/${gameId}/fork-source`, { token }); // G1:未公开作品的取件通道 → 409(行存在但不可作来源;与 lineage 的 404 是两套口径) const blockedCover = await uploadCover(author.token, `${stamp}-blocked`); const blockedMetadata = gameMetadata({ title: blockedTitle, coverAssetId: blockedCover, }); const blockedCreated = await createGame({ token: author.token, metadata: blockedMetadata, idemKey: `lineage-blocked-${stamp}`, }); const blockedId = blockedCreated.data?.id; const blockedRevision = blockedCreated.data?.publicationRevision ?? 0; check( '禁改编 fixture(草稿)创建成功', blockedCreated.status === 200 && Boolean(blockedId), `status=${blockedCreated.status} id=${blockedId ?? ''}`, ); if (!blockedId) process.exit(1); const draftSource = await forkSourceOf(blockedId, author.token); check( '未公开作品的取件通道 → 409 FORK_SOURCE_NOT_AVAILABLE', draftSource.status === 409 && (draftSource.error?.code ?? '') === 'FORK_SOURCE_NOT_AVAILABLE', brief(draftSource), ); // G2:公开但未提升授权(forbidden)→ 403 await publishToPublic({ token: author.token, admin, gameId: blockedId, gameRevision: blockedRevision, metadata: blockedMetadata, zip: await buildZip(`blocked-${suffix}`), stamp, tag: 'blocked', label: '禁改编作品', }); const forbiddenSource = await forkSourceOf(blockedId, author.token); check( '已公开但授权为 forbidden 的取件通道 → 403 FORK_NOT_AUTHORIZED', forbiddenSource.status === 403 && (forbiddenSource.error?.code ?? '') === 'FORK_NOT_AUTHORIZED', brief(forbiddenSource), ); // G3:母版(公开 + nonCommercial)→ 元数据与公开版本行一致 const parentSource = await forkSourceOf(parentId, author.token); const forkSource = parentSource.data?.forkSource ?? null; check( '母版取件元数据 200 且带 forkSource', parentSource.status === 200 && Boolean(forkSource), brief(parentSource), ); check( 'forkSource.gameId === 母版', forkSource?.gameId === parentId, `gameId=${forkSource?.gameId ?? ''}`, ); check( 'forkSource.source === package', forkSource?.source === 'package', `source=${forkSource?.source ?? ''}`, ); check( 'forkSource.versionId === 母版当前公开版本', forkSource?.versionId === parentPublicVersionId, `versionId=${forkSource?.versionId ?? ''} 期望=${parentPublicVersionId}`, ); check( 'forkSource.sha256 / bytes 与公开版本行一致', forkSource?.sha256 === parentPublicVersion?.packageSha256 && forkSource?.bytes === parentPublicVersion?.packageBytes, `sha256=${forkSource?.sha256 ?? ''}/${parentPublicVersion?.packageSha256 ?? ''} bytes=${forkSource?.bytes ?? ''}/${parentPublicVersion?.packageBytes ?? ''}`, ); check( 'downloadPath 是同源相对路径(不含协议/主机)', typeof forkSource?.downloadPath === 'string' && forkSource.downloadPath.startsWith('/api/game-distribution/games/') && !forkSource.downloadPath.includes('://'), `downloadPath=${forkSource?.downloadPath ?? ''}`, ); check( '取件元数据不下发对象键 / OSS 域名 / 存储路径', !FORK_SOURCE_LEAK_PATTERN.test(parentSource.text) && !/https?:\/\//u.test(parentSource.text), 'pattern=objectKey|coverObjectKey|bucket|aliyuncs|oss-cn|.zip + 绝对 URL', ); // G4:按 downloadPath 真实下载并校验字节(客户端最重要的契约) const forkDownload = await downloadBinary( forkSource?.downloadPath ?? '', author.token, ); check( '取件下载 200 + application/zip', forkDownload.status === 200 && forkDownload.contentType.includes('application/zip'), `status=${forkDownload.status} content-type=${forkDownload.contentType}`, ); check( '取件下载 content-length 与实际字节数一致', Number(forkDownload.contentLength) === forkDownload.bytes.length, `content-length=${forkDownload.contentLength} actual=${forkDownload.bytes.length}`, ); check( '取件下载字节数等于元数据 bytes', forkDownload.bytes.length === forkSource?.bytes, `actual=${forkDownload.bytes.length} meta=${forkSource?.bytes ?? ''}`, ); const forkDownloadSha = createHash('sha256') .update(forkDownload.bytes) .digest('hex'); check( '取件下载 sha256 与元数据一致', forkDownloadSha === forkSource?.sha256, `download=${forkDownloadSha} meta=${forkSource?.sha256 ?? ''}`, ); check( '取件包是 ZIP(PK 头)', forkDownload.bytes.length > 2 && forkDownload.bytes[0] === 0x50 && forkDownload.bytes[1] === 0x4b, `head=${forkDownload.bytes.subarray(0, 2).toString('hex')}`, ); // G5:鉴权边界 const anonSource = await api( `/api/game-distribution/games/${parentId}/fork-source`, ); const anonDownload = await downloadBinary(forkSource?.downloadPath ?? '', ''); check( '未带 Bearer 的取件元数据 → 401', anonSource.status === 401, brief(anonSource), ); check( '未带 Bearer 的取件下载 → 401', anonDownload.status === 401, `status=${anonDownload.status}`, ); const otherAuthorSource = await forkSourceOf(parentId, forker.token); check( '另一名已登录作者也能取件(非 owner 限定)', otherAuthorSource.status === 200 && otherAuthorSource.data?.forkSource?.gameId === parentId, brief(otherAuthorSource), ); // D 段:软删除降级。删除动作在浏览器阶段(页面断言之间)触发;SKIP_BROWSER 时在这里直接删。 const deleteParent = async () => { const parentNow = await ownerGame(author.token, parentId); const revision = parentNow.game?.publicationRevision ?? 0; return api( `/api/game-distribution/my-games/${parentId}?expectedPublicationRevision=${revision}`, { method: 'DELETE', token: author.token, headers: { 'Idempotency-Key': `lineage-delete-${stamp}` }, }, ); }; // F 段:浏览器(必须在删除前打开页面断言「删除前形态」,再在页面内触发删除) await runBrowserStep({ phone: forker.phone, expectedAccountText: forkerDisplayName, childId, childTitle, parentId, parentTitle, parentAuthorName: authorDisplayName, forbiddenGameId: blockedId, deleteParent, }); if (SKIP_BROWSER) { const deleted = await deleteParent(); check( '母版软删除成功(HTTP 路径)', deleted.status === 200, brief(deleted), ); } const afterTree = await lineageOf(childId); check( '删除母版后族谱接口仍 200(锚点=子作品)', afterTree.status === 200, brief(afterTree), ); const afterNodes = afterTree.data?.nodes ?? []; check( '删除后 nodes 不再含母版', !afterNodes.some((node) => node.gameId === parentId), `nodes=${afterNodes.map((node) => node.gameId).join(',')}`, ); check( '删除后 root=null 但 rootGameId 仍为母版', afterTree.data?.root == null && afterTree.data?.rootGameId === parentId, `root=${afterTree.data?.root == null ? 'null' : 'present'} rootGameId=${afterTree.data?.rootGameId ?? ''}`, ); const afterChildNode = afterNodes.find((node) => node.gameId === childId); check( '删除后子节点保留 generation=1 与 parentGameId=母版', afterChildNode?.generation === 1 && afterChildNode?.parentGameId === parentId, `generation=${afterChildNode?.generation} parentGameId=${afterChildNode?.parentGameId ?? 'null'}`, ); check( '删除后响应文本不含母版标题与作者名', !String(afterTree.text).includes(parentTitle) && !String(afterTree.text).includes(authorDisplayName), `title=${parentTitle} author=${authorDisplayName}`, ); await checkAnchorNotReadable('已软删除锚点', parentId); // E 段:上限常量(真实 >200 节点按成本不做,见 NOTE) const lineageSourcePath = path.resolve( process.cwd(), 'server-rs/crates/module-game-distribution/src/lineage.rs', ); let lineageSource = ''; try { lineageSource = readFileSync(lineageSourcePath, 'utf8'); } catch (error) { check( '节点上限常量可读(源码钉住)', false, `读取失败 ${lineageSourcePath}: ${error?.message ?? error}`, ); } if (lineageSource) { check( '节点上限常量仍为 200(源码钉住)', /GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT:\s*usize\s*=\s*200\b/u.test( lineageSource, ), 'module-game-distribution/src/lineage.rs:22', ); } note( '未做 >200 真实节点的 truncated 断言(需要 201 个真实公开作品,成本过高);' + '本脚本只断言小树 truncated=false + 源码常量 200。若要真验截断,建议在 module-game-distribution 单测层做(已有 truncation 单测)。', ); console.log( `[lineage-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); if (failures > 0) { process.exitCode = 1; } } main().catch((error) => { console.error(`[lineage-e2e] 未捕获异常:${error?.stack ?? error}`); process.exitCode = 1; });