fa9ef9037f
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 37s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m27s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m25s
Project CI / Native shell tests (pull_request) Successful in 9m12s
三个发布 helper 补衍生作品必填 changeSummary(theme 脚本那 6 行是另一 session 已写好的,随本次一并提交);lineage 新增衍生作品 PUT 提升被拒 409 FORK_AUTHORIZATION_INHERITED 且档位不变、入口禁用态与未登录文案断言;新增贡献归集段(不变式 + 三链路传递 + 非作者 403 + 匿名 401)
2013 lines
78 KiB
JavaScript
2013 lines
78 KiB
JavaScript
// 游戏分发「创作族谱与衍生列表(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 的顺序与请求体)。
|
||
///
|
||
/// `changeSummary` 只在**衍生作品**上传(§3.4:衍生作品发布必填「本次核心改动说明」,
|
||
/// 母版传了也会被忽略)——调用方按 `fork` 是否存在决定要不要带。
|
||
async function publishToPublic({
|
||
token,
|
||
admin,
|
||
gameId,
|
||
gameRevision,
|
||
metadata,
|
||
zip,
|
||
stamp,
|
||
tag,
|
||
label,
|
||
changeSummary,
|
||
}) {
|
||
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,
|
||
...(changeSummary ? { changeSummary } : {}),
|
||
},
|
||
});
|
||
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);
|
||
// 新前端:forbidden 作品的入口**照常渲染**,但为禁用态(aria-disabled + --blocked),
|
||
// 点它只给一句说明、不展开获取面板。这里先点一次再把可观测量喂给断言。
|
||
const blockedEntry = page
|
||
.locator('.co-creation-fork-primary--blocked')
|
||
.first();
|
||
if ((await blockedEntry.count()) > 0) {
|
||
// aria-disabled 的元素会被 Playwright 的可操作性检查拦下(element is not enabled);
|
||
// 这里测的正是应用自己的禁用分支,因此用 force 绕过检查、把点击真的发出去。
|
||
await blockedEntry.click({ force: true }).catch(() => {});
|
||
await page.waitForTimeout(400);
|
||
}
|
||
const blockedTitle =
|
||
(await page
|
||
.locator('.co-creation-fork-primary--blocked')
|
||
.first()
|
||
.getAttribute('title')
|
||
.catch(() => null)) ?? '';
|
||
const blockedPanelCount = await page
|
||
.locator('#game-fork-remix-panel')
|
||
.count();
|
||
check(
|
||
'浏览器视觉:授权 forbidden 的作品入口为禁用态(aria-disabled + --blocked + 原因 title),点它不展开面板',
|
||
(await page
|
||
.locator('.co-creation-fork-primary[aria-disabled="true"]')
|
||
.count()) >= 1 &&
|
||
(await page.locator('.co-creation-fork-primary--blocked').count()) >=
|
||
1 &&
|
||
blockedTitle.includes('无法 Fork 这部作品') &&
|
||
blockedPanelCount === 0,
|
||
`forbidden=${forbiddenGameId} title=${blockedTitle.slice(0, 40)} panel=${blockedPanelCount}`,
|
||
);
|
||
|
||
// 未登录访客:入口文案是另一套(登录后一键 Fork),点击走登录门禁而不是展开面板。
|
||
const anonymousContext = await browser.newContext({
|
||
viewport: { width: 1280, height: 900 },
|
||
});
|
||
try {
|
||
const anonymousPage = await anonymousContext.newPage();
|
||
await anonymousPage.goto(`${WEB}/games/detail?id=${parentId}`, {
|
||
waitUntil: 'commit',
|
||
timeout: 120_000,
|
||
});
|
||
const anonymousEntry = anonymousPage.getByRole('button', {
|
||
name: REMIX_LOGIN_LABEL,
|
||
exact: true,
|
||
});
|
||
const anonymousEntryVisible = await visible(anonymousEntry, 60_000);
|
||
check(
|
||
'浏览器视觉:未登录访客看到的是「登录后一键 Fork」入口',
|
||
anonymousEntryVisible,
|
||
`label=${REMIX_LOGIN_LABEL}`,
|
||
);
|
||
if (anonymousEntryVisible) {
|
||
await anonymousEntry.first().click();
|
||
await anonymousPage.waitForTimeout(800);
|
||
check(
|
||
'浏览器视觉:未登录点击该入口走登录门禁(不展开获取面板)',
|
||
(await anonymousPage
|
||
.getByRole('tab', { name: '密码登录' })
|
||
.count()) >= 1 &&
|
||
(await anonymousPage
|
||
.locator('#game-fork-remix-panel')
|
||
.count()) === 0,
|
||
`loginTab=${await anonymousPage.getByRole('tab', { name: '密码登录' }).count()} panel=${await anonymousPage.locator('#game-fork-remix-panel').count()}`,
|
||
);
|
||
}
|
||
} finally {
|
||
await anonymousContext.close().catch(() => {});
|
||
}
|
||
|
||
// 父作品软删除后:树仍在,但父节点内容不得再出现。
|
||
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}`);
|
||
const childChangeSummary = 'E2E:把跳台改成三段,并重画全部背景与角色立绘';
|
||
|
||
// 衍生作品必填「本次核心改动说明」(§3.4):缺失与超短都必须失败关闭(400 + 稳定码),
|
||
// 且都不写库——先各试一次(用不同的 Idempotency-Key,失败不消耗版本号),
|
||
// 再带合法说明走完整发布链路,两头都钉住。
|
||
for (const [tag, summary, expectedCode] of [
|
||
['missing', undefined, 'FORK_CHANGE_SUMMARY_REQUIRED'],
|
||
['too-short', '改了', 'FORK_CHANGE_SUMMARY_INVALID'],
|
||
]) {
|
||
const rejected = await api(
|
||
`/api/game-distribution/games/${childId}/versions`,
|
||
{
|
||
method: 'POST',
|
||
token: forker.token,
|
||
headers: { 'Idempotency-Key': `lineage-child-summary-${tag}-${stamp}` },
|
||
body: {
|
||
packageSha256: childZip.sha256,
|
||
packageBytes: childZip.bytes.length,
|
||
packageFileCount: childZip.fileCount,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: childMetadata,
|
||
...(summary === undefined ? {} : { changeSummary: summary }),
|
||
},
|
||
},
|
||
);
|
||
check(
|
||
`衍生作品版本:核心改动说明${tag === 'missing' ? '缺失' : '超短'} → 400 ${expectedCode}`,
|
||
rejected.status === 400 && rejected.error?.code === expectedCode,
|
||
brief(rejected),
|
||
);
|
||
}
|
||
|
||
await publishToPublic({
|
||
token: forker.token,
|
||
admin,
|
||
gameId: childId,
|
||
gameRevision: childRevision,
|
||
metadata: childMetadata,
|
||
zip: childZip,
|
||
stamp,
|
||
tag: 'child',
|
||
label: '子作品',
|
||
changeSummary: childChangeSummary,
|
||
});
|
||
const childPublicVersion = (await publicDetail(childId)).data?.currentVersion;
|
||
check(
|
||
'衍生作品当前版本公开下发 changeSummary',
|
||
childPublicVersion?.changeSummary === childChangeSummary,
|
||
`changeSummary=${String(childPublicVersion?.changeSummary ?? '∅').slice(0, 40)}`,
|
||
);
|
||
const parentDetailVersion = (await publicDetail(parentId)).data
|
||
?.currentVersion;
|
||
check(
|
||
'母版当前版本不发 changeSummary(null,不是省略键)',
|
||
parentDetailVersion?.changeSummary === null,
|
||
`changeSummary=${JSON.stringify(parentDetailVersion?.changeSummary ?? '∅')}`,
|
||
);
|
||
|
||
// 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,
|
||
// 衍生节点(有父)必填核心改动说明;根节点不传(传了也会被忽略)。
|
||
...(parent
|
||
? {
|
||
changeSummary: `H) 节点 ${label} 相对 ${parent.label} 的核心改动说明`,
|
||
}
|
||
: {}),
|
||
});
|
||
const detail = await publicDetail(gameId);
|
||
check(
|
||
`H) 节点 ${label} 已公开(真实发布链路 + 真实封面)`,
|
||
published.versionId !== null && detail.data?.status === 'published',
|
||
`versionId=${published.versionId ?? '∅'} status=${detail.data?.status ?? '∅'} cover=${coverAssetId}`,
|
||
);
|
||
// 新语义:**衍生作品在创建时继承父作品档位**(创建时快照),创建后该请求里的档位字段一律被忽略;
|
||
// 母版(根)才允许用 PUT 提升。这里按「根 → 提升 / 衍生 → 断言继承」分流。
|
||
if (parent) {
|
||
const childView = await ownerGame(owner.token, gameId);
|
||
const parentView = await ownerGame(parent.owner.token, parent.gameId);
|
||
const childTier = childView.game?.forkAuthorization ?? null;
|
||
const parentTier = parentView.game?.forkAuthorization ?? null;
|
||
check(
|
||
`H) 衍生节点 ${label} 创建时继承父作品档位(child=${childTier} === parent=${parentTier})`,
|
||
childTier !== null && childTier === parentTier,
|
||
`child=${childTier} parent=${parentTier}`,
|
||
);
|
||
if (promoteTo && promoteTo !== childTier) {
|
||
// 新语义(实测确认):衍生作品创建后**禁止**再用 PUT 提升——稳定错误码 FORK_AUTHORIZATION_INHERITED,
|
||
// 且档位必须保持不变(失败关闭)。这里按实测口径钉死。
|
||
const probe = await promoteForkAuthorization({
|
||
token: owner.token,
|
||
gameId,
|
||
key: `lineage-rich-probe-promote-${label}-${stamp}`,
|
||
to: promoteTo,
|
||
});
|
||
const after = await ownerGame(owner.token, gameId);
|
||
const tierAfter = after.game?.forkAuthorization ?? null;
|
||
check(
|
||
`H) 衍生节点 ${label} 的 PUT 提升被拒(409 FORK_AUTHORIZATION_INHERITED)且档位不变`,
|
||
probe.status === 409 &&
|
||
(probe.error?.code ?? '') === 'FORK_AUTHORIZATION_INHERITED' &&
|
||
tierAfter === childTier,
|
||
`status=${probe.status} code=${probe.error?.code ?? '∅'} tier=${childTier}→${tierAfter}`,
|
||
);
|
||
}
|
||
} else if (promoteTo) {
|
||
const promoted = await promoteForkAuthorization({
|
||
token: owner.token,
|
||
gameId,
|
||
key: `lineage-rich-promote-${label}-${stamp}`,
|
||
to: promoteTo,
|
||
});
|
||
check(
|
||
`H) 母版节点 ${label} 共创授权提升为 ${promoteTo}(母版仍可提升)`,
|
||
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}`,
|
||
);
|
||
}
|
||
// ---------- I 段:贡献归集 /api/game-distribution/games/{game_id}/contribution(作者视角) ----------
|
||
// 契约(读实现 + DTO 注释):Bearer;data 直接是
|
||
// { gameId, own{playCount}, inherited{playCount}, total{playCount},
|
||
// byGeneration[{generation,gameCount,own,inherited,total}],
|
||
// directChildren[{gameId,generation,own,inherited,total}], nodeCount, truncated, truncatedReason }
|
||
// 不变式:total = own + inherited;inherited = Σ byGeneration.total = Σ directChildren.total;
|
||
// nodeCount = Σ byGeneration.gameCount(**不含根自身**);非作者按「不存在」处理。
|
||
if (RICH_TREE) {
|
||
const totals = (node) => node?.playCount ?? null;
|
||
// 先记几笔游玩,让不变式里的数字不是全 0(计数端点是公开路由;失败只记 NOTE,不作为本票的断言)
|
||
const playResults = [];
|
||
for (const [label, node, times] of [
|
||
['R', richNodes.R, 1],
|
||
['E', richNodes.E, 2],
|
||
['F1', richNodes.F1, 1],
|
||
]) {
|
||
for (let index = 0; index < times; index += 1) {
|
||
const response = await api(
|
||
`/api/game-distribution/games/${node.gameId}/plays`,
|
||
{
|
||
method: 'POST',
|
||
token: author.token,
|
||
body: { clientId: `lineage-e2e-${label}-${index}-${stamp}` },
|
||
},
|
||
);
|
||
playResults.push(`${label}:${response.status}`);
|
||
}
|
||
}
|
||
note(`游玩计数探针(让贡献不变式非零):${playResults.join(' ')}`);
|
||
|
||
const contributionResponse = await api(
|
||
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
|
||
{ token: author.token },
|
||
);
|
||
const contribution =
|
||
contributionResponse.data?.contribution ??
|
||
contributionResponse.data ??
|
||
null;
|
||
check(
|
||
'I) 作者可读贡献归集(200)',
|
||
contributionResponse.status === 200 && contribution !== null,
|
||
brief(contributionResponse),
|
||
);
|
||
const own = totals(contribution?.own);
|
||
const inherited = totals(contribution?.inherited);
|
||
const total = totals(contribution?.total);
|
||
check(
|
||
'I) 不变式:total = own + inherited',
|
||
own !== null && inherited !== null && total === own + inherited,
|
||
`own=${own} inherited=${inherited} total=${total}`,
|
||
);
|
||
const children = contribution?.directChildren ?? [];
|
||
const sumChildren = children.reduce(
|
||
(sum, child) => sum + (totals(child.total) ?? 0),
|
||
0,
|
||
);
|
||
check(
|
||
'I) 不变式:inherited = Σ directChildren.total',
|
||
inherited === sumChildren && children.length === 3,
|
||
`inherited=${inherited} Σchildren=${sumChildren} children=${children
|
||
.map((child) => `${child.gameId.slice(-6)}:${totals(child.total)}`)
|
||
.join(',')}`,
|
||
);
|
||
const generations = contribution?.byGeneration ?? [];
|
||
const sumGenerationTotals = generations.reduce(
|
||
(sum, entry) => sum + (totals(entry.total) ?? 0),
|
||
0,
|
||
);
|
||
const sumGenerationCounts = generations.reduce(
|
||
(sum, entry) => sum + (entry.gameCount ?? 0),
|
||
0,
|
||
);
|
||
check(
|
||
'I) 不变式:inherited = Σ byGeneration.total',
|
||
inherited === sumGenerationTotals,
|
||
`inherited=${inherited} Σgen=${sumGenerationTotals} gen=${JSON.stringify(
|
||
generations.map((entry) => [
|
||
entry.generation,
|
||
entry.gameCount,
|
||
totals(entry.total),
|
||
]),
|
||
)}`,
|
||
);
|
||
check(
|
||
'I) 不变式:nodeCount = Σ byGeneration.gameCount = 5(不含根自身)',
|
||
contribution?.nodeCount === sumGenerationCounts &&
|
||
contribution?.nodeCount === 5,
|
||
`nodeCount=${contribution?.nodeCount} ΣgameCount=${sumGenerationCounts}`,
|
||
);
|
||
check(
|
||
'I) 小树不截断(truncated=false / truncatedReason=null)',
|
||
contribution?.truncated === false &&
|
||
(contribution?.truncatedReason ?? null) === null,
|
||
`truncated=${contribution?.truncated} reason=${contribution?.truncatedReason ?? 'null'}`,
|
||
);
|
||
|
||
// 三层链路的传递性:R.inherited = B1.total + C1.total + F1.total;B1.total = B1.own + D.total
|
||
const b1ContributionResponse = await api(
|
||
`/api/game-distribution/games/${richNodes.B1.gameId}/contribution`,
|
||
{ token: author.token },
|
||
);
|
||
const b1Contribution =
|
||
b1ContributionResponse.data?.contribution ??
|
||
b1ContributionResponse.data ??
|
||
null;
|
||
const dOfB1 = (b1Contribution?.directChildren ?? []).find(
|
||
(child) => child.gameId === richNodes.D.gameId,
|
||
);
|
||
const findChild = (id) => children.find((child) => child.gameId === id);
|
||
check(
|
||
'I) 链路传递:B1.total = B1.own + D.total',
|
||
totals(b1Contribution?.total) ===
|
||
(totals(b1Contribution?.own) ?? NaN) + (totals(dOfB1?.total) ?? NaN),
|
||
`B1.own=${totals(b1Contribution?.own)} D.total=${totals(dOfB1?.total)} B1.total=${totals(b1Contribution?.total)}`,
|
||
);
|
||
check(
|
||
'I) 链路传递:R.inherited = B1.total + C1.total + F1.total',
|
||
inherited ===
|
||
['B1', 'C1', 'F1'].reduce(
|
||
(sum, key) =>
|
||
sum + (totals(findChild(richNodes[key].gameId)?.total) ?? NaN),
|
||
0,
|
||
),
|
||
`R.inherited=${inherited} Σ=${['B1', 'C1', 'F1']
|
||
.map(
|
||
(key) =>
|
||
`${key}:${totals(findChild(richNodes[key].gameId)?.total)}`,
|
||
)
|
||
.join(',')}`,
|
||
);
|
||
// E 属作者乙(forker)——贡献归集是**作者视角**接口,非作者会 403,所以用它的 owner token 查。
|
||
const eContributionResponse = await api(
|
||
`/api/game-distribution/games/${richNodes.E.gameId}/contribution`,
|
||
{ token: forker.token },
|
||
);
|
||
const eContribution =
|
||
eContributionResponse.data?.contribution ??
|
||
eContributionResponse.data ??
|
||
null;
|
||
check(
|
||
'I) 最深一层 E:无子代(nodeCount=0、inherited=0、total=own)',
|
||
eContribution?.nodeCount === 0 &&
|
||
totals(eContribution?.inherited) === 0 &&
|
||
totals(eContribution?.total) === totals(eContribution?.own),
|
||
`nodeCount=${eContribution?.nodeCount} own=${totals(eContribution?.own)} inherited=${totals(eContribution?.inherited)} total=${totals(eContribution?.total)}`,
|
||
);
|
||
|
||
const foreignContribution = await api(
|
||
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
|
||
{ token: forker.token },
|
||
);
|
||
check(
|
||
'I) 非作者读贡献归集被拒(403 FORBIDDEN「游戏 owner 不匹配」,不泄露计数)',
|
||
foreignContribution.status === 403 &&
|
||
(foreignContribution.error?.code ?? '') === 'FORBIDDEN' &&
|
||
!foreignContribution.text.includes('playCount'),
|
||
brief(foreignContribution),
|
||
);
|
||
const anonymousContribution = await api(
|
||
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
|
||
);
|
||
check(
|
||
'I) 匿名读贡献归集 → 401',
|
||
anonymousContribution.status === 401,
|
||
brief(anonymousContribution),
|
||
);
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
});
|