Files
Genarrative/scripts/check-game-distribution-lineage-e2e.mjs
T
suzmii 6cea0a8cd9
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m8s
Project CI / Repository checks (pull_request) Failing after 42s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m21s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m26s
Project CI / Native shell tests (pull_request) Successful in 7m6s
族谱卡:封面 16:9 横版、轴统一主题色、控件入画布浮层、去掉第三个动作
2026-10-06 15:28:00 +08:00

1694 lines
64 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';
// H 段(富树 fixture:1 根 + 3 子 + 1 孙 + 1 曾孙,供网页端族谱可视化截图)默认执行;
// CI/快速回归可用 LINEAGE_RICH_TREE=0 关闭(它会多造 6 个真实公开作品,逐一走完整发布链路)。
const RICH_TREE = (process.env.LINEAGE_RICH_TREE ?? '1').trim() !== '0';
const LINEAGE_ENTRY_LABEL =
process.env.E2E_LINEAGE_LABEL_ENTRY ?? '查看创作族谱';
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 ?? 'Fork 这个作品';
const REMIX_LOGIN_LABEL = process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后可 Fork';
const REMIX_STEPS_TEXT =
process.env.E2E_REMIX_STEPS_TEXT ?? '从平台作品开始创作';
const REMIX_NOTE_TEXT = process.env.E2E_REMIX_NOTE_TEXT ?? '整个工程';
const REMIX_NOTE_EXTRA_TEXT =
process.env.E2E_REMIX_NOTE_EXTRA_TEXT ?? '不可撤销';
const REMIX_COPY_ID_LABEL =
process.env.E2E_REMIX_COPY_ID_LABEL ?? '复制作品 ID';
const REMIX_COPIED_ID_LABEL =
process.env.E2E_REMIX_COPIED_ID_LABEL ?? '作品 ID 已复制';
// 节点键集合由实现钉死(shared-contracts/src/game_distribution.rs:262-281),多一个键就失败。
const LINEAGE_NODE_KEYS = [
'authorName',
// 本轮起服务端在族谱节点上下发封面对象键(客户端据此渲染缩略图)——它是有意对外暴露的公开字段。
'coverObjectKey',
'gameId',
'generation',
'parentGameId',
'playCount',
'status',
'title',
];
// 这 8 个键之外不该出现的东西:一般对象键 / 素材键 / 包摘要等私有字段。
// 注意:`coverObjectKey` 本轮起是**有意下发**的公开字段(客户端渲染缩略图用),
// 因此用 (?<!cover) 把 `coverObjectKey` 排除在「对象键」之外,其余对象键仍然禁止。
const PRIVATE_KEY_PATTERN =
/(?<!cover)objectKey|assetId|screenshots|packageSha|sha256|bucket|\boss\b|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 };
}
/// 取某作品「当前公开版本」的 id:改编声明的 `parentVersionId` 必须是父作品的当前公开版本。
async function publicVersionIdOf(token, gameId) {
const versions = (await ownerGame(token, gameId)).game?.versions ?? [];
return (
versions.find((version) => version.status === 'published')?.versionId ?? ''
);
}
/// 提升作品共创授权(H 段造富树时给「需要继续被 Fork」的节点用;不动既有 A 的档位)。
async function promoteForkAuthorization({
token,
gameId,
from = 'forbidden',
to = 'nonCommercial',
key,
}) {
return api(`/api/game-distribution/games/${gameId}/fork-authorization`, {
method: 'PUT',
token,
headers: { 'Idempotency-Key': key },
body: { expectedForkAuthorization: from, forkAuthorization: to },
});
}
/// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。
async function publishToPublic({
token,
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`);
}
function publicDetail(gameId) {
return api(`/api/game-distribution/games/${gameId}`);
}
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: 240_000,
state: 'attached',
})
.catch(async () => {
// 首次导航可能触发 Vite 冷编译(重启/rebase 后的首屏实测可超过 2 分钟):
// 重载一次再等,避免把「开发服务器还没编译完」误判成页面坏了。
await page
.reload({ waitUntil: 'commit', timeout: 120_000 })
.catch(() => {});
await page.waitForSelector('.platform-account-entry', {
timeout: 240_000,
state: 'attached',
});
});
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 {
// 复制反馈要真跑成功路径:headless 下不授剪贴板权限时回退路径(execCommand)会失败并显示
// 「复制失败」,那是环境限制而不是产品缺陷 —— 这里按真实用户条件授予剪贴板权限。
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
permissions: ['clipboard-read', 'clipboard-write'],
});
const page = await context.newPage();
await loginThroughWebUi(page, phone, expectedAccountText);
check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`);
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, 15_000)) &&
(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 }))) &&
(await visible(
page.getByText(REMIX_NOTE_EXTRA_TEXT, { exact: false }),
)),
`note 需同时含 ${REMIX_NOTE_TEXT} 与 ${REMIX_NOTE_EXTRA_TEXT}`,
);
const remixShot = path.join(screenshotDir, 'game-remix-panel.png');
await page.screenshot({ path: remixShot, fullPage: true });
check(
'浏览器视觉:改造入口展开态已截图',
true,
`截图路径=${remixShot}`,
);
// 复制反馈:点击后必须有**可读反馈**——要么按钮变「作品 ID 已复制」,
// 要么出现「复制失败:请手动选中作品 ID 文本。」。
//
// 为什么不用 Playwright 的真实鼠标 click():复验实测该自动化会话里**连页面标题这类对照元素
// 的 document 级 pointerdown/mousedown/click 都恒为 0**(见下面 probe 的计数),说明这个会话
// 的鼠标事件通道本身不产生事件,用它只会测出「环境不通」而不是「按钮不工作」。
// 独立复验用同一页面 `btn.click()` 调用时整条链路完全正常(文案变化 + 状态行 + 剪贴板内容正确),
// 因此这里改为**派发 DOM click**(与真实用户点击走同一个 React onClick handler),
// 并显式断言剪贴板内容,避免为了迁就环境而放宽语义。产品代码未为此改动。
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) {
// 诊断探针:记录 document 级鼠标事件计数,作为「本会话鼠标通道是否可用」的证据。
const mouseProbe = await page
.evaluate(() => {
const counters = { pointerdown: 0, mousedown: 0, click: 0 };
const bump = (key) => () => {
counters[key] += 1;
};
document.addEventListener('pointerdown', bump('pointerdown'), true);
document.addEventListener('mousedown', bump('mousedown'), true);
document.addEventListener('click', bump('click'), true);
window.__lineageMouseProbe = counters;
return counters;
})
.catch(() => null);
await page.bringToFront().catch(() => {});
await copyId.hover({ timeout: 5_000 }).catch(() => {});
// 派发 DOM click:不依赖该会话的鼠标通道,但会真正触发 React 的 onClick。
await copyId.evaluate((element) => {
element.click();
});
const copiedShown = await visible(
page.getByRole('button', {
name: REMIX_COPIED_ID_LABEL,
exact: true,
}),
15_000,
);
const failedShown = copiedShown
? false
: await visible(
page.getByText('复制失败:请手动选中作品 ID 文本。', {
exact: false,
}),
5_000,
);
const observedLabels = await page
.locator('#game-fork-remix-panel button.game-fork-remix-copy')
.evaluateAll((nodes) => nodes.map((node) => node.textContent))
.catch(() => []);
const observedStatusTexts = await page
.locator('#game-fork-remix-panel .game-fork-remix-status')
.evaluateAll((nodes) => nodes.map((node) => node.textContent))
.catch(() => []);
const clipboardText = await page
.evaluate(async () => {
try {
return await navigator.clipboard.readText();
} catch {
return '';
}
})
.catch(() => '');
check(
'浏览器视觉:点「复制作品 ID」后给出可读反馈(已复制按钮或复制失败提示)',
copiedShown || failedShown,
`期望按钮名=${REMIX_COPIED_ID_LABEL} 或出现失败提示;实测按钮文案=${JSON.stringify(observedLabels)} 面板状态行=${JSON.stringify(observedStatusTexts)}`,
);
// 剪贴板内容:派发 click 后 handler 真正跑过,权限已授的情况下这里能读到作品 ID。
check(
'浏览器视觉:复制后剪贴板内容为该作品 ID',
clipboardText.trim() === parentId,
`clipboard=${JSON.stringify(clipboardText.trim())} 期望=${parentId}`,
);
const mouseProbeAfter = await page
.evaluate(() => window.__lineageMouseProbe ?? null)
.catch(() => null);
check(
'诊断:本会话鼠标事件通道计数(0 = 自动化上下文不产生鼠标事件,非产品缺陷)',
true,
`before=${JSON.stringify(mouseProbe)} after=${JSON.stringify(mouseProbeAfter)}`,
);
}
}
// 对照:已公开但授权仍为 forbidden 的作品不得出现该入口(也不得出现未登录态文案)。
await page.goto(`${WEB}/games/detail?id=${forbiddenGameId}`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page.waitForTimeout(1500);
check(
'浏览器视觉:授权 forbidden 的作品详情页没有「Fork 这个作品」入口',
(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);
// ---------- H 段:富树 fixture(1 根 + 3 子 + 1 孙 + 1 曾孙,供族谱可视化截图) ----------
//
// 为什么要独立一棵树:既有 A/B 那段断言里「衍生列表恰好 1 个子代」是精确断言(不动它),
// 所以富树另起一根,既不改旧断言逻辑,也能独立断言自己的形状。
// 每个节点都走真实发布链路(建作品 → 建版本 → 上传发行包 → 送审 → 管理员通过)并带真实封面;
// 血缘用 `fork: { parentGameId, parentVersionId }`(父必须是已公开且有当前公开版本)。
if (!RICH_TREE) {
skip('H) 富树 fixture(LINEAGE_RICH_TREE=0 已关闭)');
} else {
/// 造一个「已公开」节点(可选:带改编声明、可选:提升共创授权以便继续被 Fork)。
const createPublishedNode = async ({
label,
title,
owner,
ownerLabel,
parent,
promoteTo,
}) => {
const coverAssetId = await uploadCover(
owner.token,
`${stamp}-rich-${label}`,
);
const parentVersionId = parent
? await publicVersionIdOf(parent.owner.token, parent.gameId)
: '';
if (parent && !parentVersionId) {
check(
`H) 节点 ${label} 的父作品(${parent.label})有当前公开版本`,
false,
`parent=${parent.gameId}`,
);
}
const metadata = gameMetadata({ title, coverAssetId });
const created = await createGame({
token: owner.token,
metadata,
idemKey: `lineage-rich-${label}-${stamp}`,
...(parent
? { fork: { parentGameId: parent.gameId, parentVersionId } }
: {}),
});
const gameId = created.data?.id;
check(
`H) 节点 ${label}(${ownerLabel}${parent ? `,父=${parent.label}` : ',根'})创建成功`,
created.status === 200 && Boolean(gameId),
brief(created),
);
if (!gameId) return { gameId: null, label, owner, ownerLabel };
const zip = await buildZip(`rich-${label}-${stamp}`);
const published = await publishToPublic({
token: owner.token,
admin,
gameId,
gameRevision: created.data?.publicationRevision ?? 0,
metadata,
zip,
stamp,
tag: `rich-${label}`,
label: `H) 节点 ${label}`,
quiet: true,
});
const detail = await publicDetail(gameId);
check(
`H) 节点 ${label} 已公开(真实发布链路 + 真实封面)`,
published.versionId !== null && detail.data?.status === 'published',
`versionId=${published.versionId ?? '∅'} status=${detail.data?.status ?? '∅'} cover=${coverAssetId}`,
);
if (promoteTo) {
const promoted = await promoteForkAuthorization({
token: owner.token,
gameId,
key: `lineage-rich-promote-${label}-${stamp}`,
to: promoteTo,
});
check(
`H) 节点 ${label} 共创授权提升为 ${promoteTo}(供后续继续 Fork)`,
promoted.status === 200 &&
(promoted.data?.game?.forkAuthorization ?? null) === promoteTo,
brief(promoted),
);
}
return { gameId, label, owner, ownerLabel, metadata };
};
const richRoot = await createPublishedNode({
label: 'R',
title: `富树母版 ${suffix}`,
owner: author,
ownerLabel: '作者甲',
promoteTo: 'nonCommercial',
});
const richB1 = await createPublishedNode({
label: 'B1',
title: `富树分支 B1 ${suffix}`,
owner: author,
ownerLabel: '作者甲',
parent: richRoot,
promoteTo: 'nonCommercial',
});
const richC1 = await createPublishedNode({
label: 'C1',
title: `富树分支 C1 ${suffix}`,
owner: forker,
ownerLabel: '作者乙',
parent: richRoot,
promoteTo: 'full',
});
const richF1 = await createPublishedNode({
label: 'F1',
title: `富树分支 F1 ${suffix}`,
owner: forker,
ownerLabel: '作者乙',
parent: richRoot,
// 刻意保持默认 forbidden:多档位在图上可见
});
const richD = await createPublishedNode({
label: 'D',
title: `富树二代 D ${suffix}`,
owner: author,
ownerLabel: '作者甲',
parent: richB1,
promoteTo: 'nonCommercial',
});
const richE = await createPublishedNode({
label: 'E',
title: `富树三代 E ${suffix}`,
owner: forker,
ownerLabel: '作者乙',
parent: richD,
});
const richNodes = {
R: richRoot,
B1: richB1,
C1: richC1,
F1: richF1,
D: richD,
E: richE,
};
const richRootId = richRoot.gameId;
const allCreated = Object.values(richNodes).every((node) => node.gameId);
check(
'H) 富树 6 个节点全部建成(1 根 + 3 子 + 1 孙 + 1 曾孙)',
allCreated,
`ids=${Object.entries(richNodes)
.map(([key, node]) => `${key}:${node.gameId ?? '∅'}`)
.join(' ')}`,
);
if (allCreated) {
const expectedRich = [
{ key: 'R', generation: 0, parent: null, ownerLabel: '作者甲' },
{ key: 'B1', generation: 1, parent: 'R', ownerLabel: '作者甲' },
{ key: 'C1', generation: 1, parent: 'R', ownerLabel: '作者乙' },
{ key: 'F1', generation: 1, parent: 'R', ownerLabel: '作者乙' },
{ key: 'D', generation: 2, parent: 'B1', ownerLabel: '作者甲' },
{ key: 'E', generation: 3, parent: 'D', ownerLabel: '作者乙' },
];
const richTree = await lineageOf(richRootId);
check(
'H) 富树 /lineage 200 且未截断',
richTree.status === 200 && richTree.data?.truncated === false,
brief(richTree),
);
check(
'H) rootGameId 与 root 都指向富树根',
richTree.data?.rootGameId === richRootId &&
richTree.data?.root?.gameId === richRootId,
`rootGameId=${richTree.data?.rootGameId ?? ''} root=${richTree.data?.root?.gameId ?? 'null'}`,
);
const richTreeNodes = richTree.data?.nodes ?? [];
check(
'H) 树恰好含 6 个节点(不多不少)',
richTreeNodes.length === 6,
`nodes=${richTreeNodes.length} ids=${richTreeNodes.map((node) => node.gameId).join(',')}`,
);
const richById = new Map(
richTreeNodes.map((node) => [node.gameId, node]),
);
for (const expectation of expectedRich) {
const node = richById.get(richNodes[expectation.key].gameId);
const expectedParentId = expectation.parent
? richNodes[expectation.parent].gameId
: null;
check(
`H) 节点 ${expectation.key}:generation=${expectation.generation}、parentGameId=${expectation.parent ?? 'null'}`,
node?.generation === expectation.generation &&
(expectation.parent
? node?.parentGameId === expectedParentId
: node?.parentGameId == null),
`generation=${node?.generation ?? '∅'} parentGameId=${node?.parentGameId ?? 'null'}`,
);
checkNodeShape(`H) 富树节点 ${expectation.key}`, node);
const expectedAuthorName =
expectation.ownerLabel === '作者甲'
? authorDisplayName
: forkerDisplayName;
check(
`H) 节点 ${expectation.key} 作者名来自账号展示名(${expectation.ownerLabel})`,
node?.authorName === expectedAuthorName,
`authorName=${node?.authorName ?? 'null'} 期望=${expectedAuthorName}`,
);
}
checkNoPrivateKeys('H) 富树响应', richTree);
const richGenerations = richTreeNodes.map((node) => node.generation);
check(
'H) 富树按代际升序(根 → 3 个 1 代 → 2 代 → 3 代)',
richGenerations.every(
(value, index) => index === 0 || richGenerations[index - 1] <= value,
) &&
richGenerations[0] === 0 &&
richGenerations[richGenerations.length - 1] === 3,
`generations=${richGenerations.join(',')}`,
);
check(
'H) 树上同时出现两个不同作者(分支多样性在图上可见)',
new Set(richTreeNodes.map((node) => node.authorName)).size === 2,
`authors=${[...new Set(richTreeNodes.map((node) => node.authorName))].join(' / ')}`,
);
const richDerived = await derivedOf(richRootId);
const richDerivedIds = (richDerived.data?.nodes ?? [])
.map((node) => node.gameId)
.sort();
const expectedChildIds = ['B1', 'C1', 'F1']
.map((key) => richNodes[key].gameId)
.sort();
check(
'H) 富树 /derived 恰好含根的直接子(B1/C1/F1)',
richDerived.status === 200 &&
JSON.stringify(richDerivedIds) === JSON.stringify(expectedChildIds),
`derived=${richDerivedIds.join(',')} 期望=${expectedChildIds.join(',')}`,
);
const deepAnchorTree = await lineageOf(richNodes.E.gameId);
check(
'H) 最深锚点(E,第 3 代)也返回同一棵 6 节点树',
deepAnchorTree.status === 200 &&
deepAnchorTree.data?.rootGameId === richRootId &&
(deepAnchorTree.data?.nodes ?? []).length === 6,
`status=${deepAnchorTree.status} rootGameId=${deepAnchorTree.data?.rootGameId ?? ''} nodes=${(deepAnchorTree.data?.nodes ?? []).length}`,
);
console.log(
`[lineage-e2e] 富树根 id=${richRootId}(6 节点 / 3 层分支 / 1 条 3 代深链)`,
);
console.log(
`[lineage-e2e] 富树 lineage URL=${WEB}/games/lineage?id=${richRootId}`,
);
}
}
// G 段:Fork 取件通道(M2a)
// 契约(读代码确认):路由 api-server/...:307-320(Bearer + no-store,不叠发布灰度);
// 404/409/403 映射 api-server/...:2519-2560(FORK_SOURCE_NOT_FOUND / NOT_AVAILABLE / NOT_AUTHORIZED);
// 元数据载荷 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;
});