Files
Genarrative/scripts/check-game-distribution-lineage-e2e.mjs
T
suzmii f40050afb1 收口 M3 复核遗留:文档如实口径、fork-source 状态说明、forkAuthorization 机器守卫、e2e 引用去行号
- 技术方案 §3.4:删掉「GET /games/{gameId} 追加 forkSourceAvailable」——实现里不存在该字段;改为如实口径:改造入口显隐只依据 forkAuthorization,真实可复刻形态由 /fork-source 的 source 回答,若 M2b 要区分「可源码级改造 / 只能参考」届时再加
- 实施计划(M1)§5:同步去掉 forkSourceAvailable 并注明「未实现、也不需要」,保留其余字段清单不动
- 技术方案 §2.6 时序图:说明字句从「GET …/fork-source 是待实现接口」改为「M2a 已实现(形状见 §3.4 登录用户表)」,并在图内加一行 Note 标注「图中字段名是 M2b 目标形状」;图属目标形态的免责声明保留
- parity 脚本:game_payload 增加 mustEmit ['forkAuthorization']——该字段 TS 侧可选(旧响应可省),但公开与作者响应必须发出,否则详情页授权徽章与改造入口会静默退化成「禁止共创」;public_game_payload 以 game_payload 为 base,因此一并受保护
- e2e 脚本头部:把全部行号引用(含已过期 2–4 行的那些)改为「文件 + 符号」引用(router()/lineage_read_or_not_found()/GameDistributionLineageNode/lineage.rs 常量与函数等),并删除「消费链路在未提交的 M2a WIP 里」这类会过期的措辞,改为「已在 M2a 落地」;只改注释,断言逻辑未动
2026-10-05 17:30:47 +08:00

1314 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发「创作族谱与衍生列表(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',
`<!doctype html><html><head><meta charset="utf-8"><title>lineage ${marker}</title>` +
'<script src="assets/app.js"></script></head><body><h1>' +
marker +
'</h1></body></html>',
);
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;
});