From 93080292761aad5da501abf5c4273c4880a98fdf Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 02:05:22 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=88=9B=E4=BD=9C=E6=97=8F=E8=B0=B1/?= =?UTF-8?q?=E8=A1=8D=E7=94=9F=E5=88=97=E8=A1=A8=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E8=84=9A=E6=9C=AC=EF=BC=88=E5=90=AB=20Fork?= =?UTF-8?q?=20=E5=8F=96=E4=BB=B6=E9=80=9A=E9=81=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/check-game-distribution-lineage-e2e.mjs:真实 HTTP + 浏览器验收 M3 与 M2a · 404 规则:未公开草稿锚点与不存在的 gameId,/lineage 与 /derived 都断言 404 且不是成功信封空树,并断言 no-store · 真实树:完整发布链路(建版本→传包→送审→管理员通过)把母版与带改编声明的子作品推公开, 断言 rootGameId/root/节点集合/generation/parentGameId/status/作者名读取自账号展示名/代际升序/ 节点键集合恰好 7 个/响应不含对象键与素材键 · 衍生列表:锚点=母版时恰好含直接子作品且未截断 · 软删除降级:作者软删除母版后 nodes 不再含母版、root=null 但 rootGameId 仍回传、 子节点保留 generation/parentGameId、响应文本不含母版标题与作者名、已删锚点两个接口都 404 · Fork 取件通道(M2a):未公开作品 409、公开但授权 forbidden 403、元数据 sha256/bytes/versionId 与公开版本行一致、downloadPath 为同源相对路径、按该路径下载后校验 content-type/长度/sha256/ZIP 头、 未带 Bearer 401、另一名已登录作者可取件 200 · 浏览器:详情页「查看创作族谱」入口 → /games/lineage 树渲染(母版/当前作品/作者名)→ 页面内触发父作品软删除 → 降级为「原作品已不可用」且父标题不再出现,删除前后各留截图 · 节点上限 200 未用真实 201 节点验证:改为源码常量钉住并在脚本里 NOTE 说明降级理由 - package.json:新增 npm script check:game-distribution-lineage-e2e,命名与既有 check:game-distribution-*-e2e 一致 - 实测(本地 dev 栈 + Chrome + 临时 playwright):本脚本 90 项 90 PASS / 0 FAIL; 同时复跑 check:game-distribution-fork-authorization-e2e 48/48 未回归 --- package.json | 1 + .../check-game-distribution-lineage-e2e.mjs | 1188 +++++++++++++++++ 2 files changed, 1189 insertions(+) create mode 100644 scripts/check-game-distribution-lineage-e2e.mjs diff --git a/package.json b/package.json index b3a27df3c..43f20797b 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "check:game-distribution-validation-restart-e2e": "node scripts/check-game-distribution-validation-restart-e2e.mjs", "check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs", "check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs", + "check:game-distribution-lineage-e2e": "node scripts/check-game-distribution-lineage-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs new file mode 100644 index 000000000..273f3a238 --- /dev/null +++ b/scripts/check-game-distribution-lineage-e2e.mjs @@ -0,0 +1,1188 @@ +// 游戏分发「创作族谱与衍生列表(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:419-426(public_games) +// [2] 「不可读即 404」映射:api-server/src/modules/game_distribution.rs:1090-1092(lineage_read_or_not_found) +// [3] 两个 handler:api-server/src/modules/game_distribution.rs:1096(get_game_lineage)/ :1114(get_game_derived_games) +// [4] 节点 DTO 的 7 个键:server-rs/crates/shared-contracts/src/game_distribution.rs:262-281 +// 响应::283-293(LineageResponse)/ :295-301(DerivedResponse) +// [5] 上限 200 / 锚点可读性 / 稳定排序:server-rs/crates/module-game-distribution/src/lineage.rs:22 / :28-30 / :135-143 +// [6] 作者软删除:api-server/src/modules/game_distribution.rs:1518-1550;事务语义(撤销当前公开版本 + deleted_at) +// server-rs/crates/spacetime-module/src/game_distribution.rs:3599-3660 +// [7] 改编声明 DTO:shared-contracts/src/game_distribution.rs:241-245 + :367-368; +// 消费点 api-server/...:1611-1615;事务校验 spacetime-module/...:2155-2160 + :2250-2254 +// [8] 发布链路(建版本 → 传包 → 送审 → 管理员通过)照抄 scripts/check-game-distribution-media-e2e.mjs:391-400,431-450,455-470,479-495,523-527,561-573 +// [9] 封面直传 + 确认:scripts/check-game-distribution-owner-isolation.mjs:97-150 +// [10] 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27 +// [11] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159 +// [12] 前端契约:详情页入口 GameDetailPage.tsx:409「查看创作族谱」;族谱页 GameLineagePage.tsx:65-70(节点卡片类名)、 +// :93「原作品已不可用」、:163-164 标题;路由 /games/lineage:src/routing/activeAppPageRoutes.ts:18 +// [13] 节点作者名读时联账号表:spacetime-module/src/game_distribution.rs:4294-4308(所以脚本会先给作者改名,让「删除后不外发作者名」这条断言有意义) +// +// 依赖提示(重要): +// 改编声明 `fork: { parentGameId, parentVersionId }` 的消费链路当前在**未提交的 M2a WIP** 里 +// (工作树 api-server/...:1611-1615 已接,但提交基线 4207d24f0 尚未包含)。在 M2a 落地前: +// - A 段(404 规则)与 E 段的「未公开锚点 404」可以照跑; +// - B/C 段(真实树、软删除降级)会因为创建 B 时 fork 声明不生效而失败 —— 那是依赖未就绪,不是脚本问题。 +// +// 契约假设(读代码确认,不猜): +// - 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 ?? '原作品已不可用'; + +// 节点键集合由实现钉死(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, + 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 deleted = await deleteParent(); + check( + '(前置)浏览器阶段完成父作品软删除', + deleted?.status === 200, + brief(deleted), + ); + await page.reload({ 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, + 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; +});