From 70bba0f7b2fb0cb3d09be4a700564261d1710945 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 11:00:28 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=E5=A4=B9=E5=85=B7=E7=B1=BB?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E9=BB=98=E8=AE=A4=E6=89=93=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=A0=88=E5=B9=B6=E5=8A=A0=E6=8A=A4=E6=A0=8F/=E8=87=AA?= =?UTF-8?q?=E6=B8=85=E7=90=86=EF=BC=8Cdev=20=E6=A0=88=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E5=8F=AF=E9=87=8D=E5=AE=9A=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 15 ++ package.json | 2 + scripts/capture-game-lineage-visual.mjs | 26 +- ...check-game-distribution-collection-e2e.mjs | 71 ++++-- ...me-distribution-fork-authorization-e2e.mjs | 77 ++++-- .../check-game-distribution-lineage-e2e.mjs | 90 ++++--- ...k-game-distribution-project-bundle-e2e.mjs | 72 ++++-- scripts/check-game-distribution-theme-e2e.mjs | 78 ++++-- scripts/dev.mjs | 25 ++ scripts/e2e-stack.mjs | 188 ++++++++++++++ scripts/e2e-target.mjs | 237 ++++++++++++++++++ .../seed-game-distribution-co-creation.mjs | 2 + 12 files changed, 752 insertions(+), 131 deletions(-) create mode 100644 scripts/e2e-stack.mjs create mode 100644 scripts/e2e-target.mjs diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1c3c60260..d6b39a606 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -106,6 +106,21 @@ npm run dev:all 通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 +### 本地 e2e 必须用隔离栈(2026-10-07) + +**背景(实际事故)**:夹具类 e2e 脚本过去缺省跟着 `.app/dev-stack.json` 打**用户正在用的开发栈**,而「换端口」并不等于「换库」——`npm run dev` 与早期 pinned 端口那套都指向同一个 `genarrative-game-creator-dev` + 同一份 data dir。结果是跑几轮 e2e 就把夹具写进用户的库:实测盘点时该库有 **105 条作品 = 13 条演示 + 92 条夹具**(lineage 4 轮×9、theme 1 轮×54、collection 2、project-bundle 2、fork-authorization 2),而公开目录上限只有 48 条,用户的共创页几乎被夹具占满。 + +**现在的口径(`scripts/e2e-target.mjs` 是单一事实来源)**: + +1. **缺省隔离**:夹具类脚本在未显式给 `E2E_API_BASE` 时,目标固定为隔离栈 `http://127.0.0.1:8188`(web `3112`),不再回落到 dev-stack 标记。 +2. **护栏**:解析出的目标与 `.app/dev-stack.json` 记录的 api-server **同地址或同端口**时,脚本**直接拒绝启动**(`exit 2`),提示二选一——用隔离栈,或 `E2E_API_BASE=… E2E_ALLOW_DEV_STACK=1` 明确越权。护栏只管**会写夹具**的脚本;只读工具(截图工具的「只截现有作品」模式)仍按 dev-stack 解析。 +3. **一键隔离栈**:`npm run e2e:stack` 只起栈(前台长驻,Ctrl-C 结束);`npm run e2e:run -- --suite scripts/check-game-distribution-lineage-e2e.mjs [--suite …]` 起栈→等 `/healthz`→顺序跑 suite→自动停栈。隔离栈用**专用端口**(api 8188 / bgfilter 8189 / spacetime 3110 / admin-web 3111 / web 3112)、**专用库名** `genarrative-game-creator-e2e` 与 **临时 data-dir**(`%TEMP%/genarrative-e2e-spacetime-*`,默认跑完删除;`--keep-data` / `--keep-stack` 可保留)。 +4. **夹具统一前缀 `E2E·`**:所有夹具标题都带它(`fixtureTitle()`),盘点/清理不再依赖随机数字后缀;`tags` 之外,标题前缀是**盘点唯一可靠标识**。 +5. **跑批后自清理**:每个脚本正常结束时用本轮 owner token 把「自己造的、标题带 `E2E·` 且含本轮后缀」的作品逐条软删(owner 维度 `DELETE /api/game-distribution/my-games/{id}?expectedPublicationRevision=…`),并打印删除条数;清理失败或脚本中断时,`main().catch()` 会打印残留清单(owner + gameId + 标题),便于人工补删。 +6. **例外**:`scripts/seed-game-distribution-co-creation.mjs` **有意**写用户的 dev 栈(它就是演示数据灌装工具,幂等、判重不绑账号),不受护栏约束;要写到别处请显式给 `E2E_API_BASE`。 + +**软删的语义**:`DELETE …/my-games/{id}` 只是软删(撤销当前公开版本 + 写 `deleted_at`),公开目录、族谱树、`my-games` 立刻都看不到,但**行仍在库里**、可由后台 `restore` 路径恢复;已软删的行不会出现在任何列表接口里,所以「清理后列表为空」不等于「行被物理删除」。 + 单独启动主站前端: ```bash diff --git a/package.json b/package.json index 5d2aa5a58..c1ca46a40 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ "check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs", "check:agc-update-channel-manifests": "node scripts/check-agc-update-channel-manifests.mjs", "check:agc-co-creation-fork-e2e": "node scripts/check-agc-co-creation-fork-e2e.mjs", + "e2e:stack": "node scripts/e2e-stack.mjs --stack-only", + "e2e:run": "node scripts/e2e-stack.mjs", "check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-e2e.mjs", "check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs", "check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs", diff --git a/scripts/capture-game-lineage-visual.mjs b/scripts/capture-game-lineage-visual.mjs index 19a82d0fd..b4ca81792 100644 --- a/scripts/capture-game-lineage-visual.mjs +++ b/scripts/capture-game-lineage-visual.mjs @@ -19,7 +19,6 @@ // - 布局纯函数:src/components/game-distribution/lineageTreeLayout.ts(缺失父作品会生成 stub 节点) 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'; @@ -28,6 +27,8 @@ import { deflateSync } from 'node:zlib'; import JSZip from 'jszip'; +import { resolveE2eTarget } from './e2e-target.mjs'; + const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; @@ -39,19 +40,16 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) { 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, ''); +// 目标地址:造树模式会写夹具 → 缺省打隔离栈并拒绝误打用户开发栈;「只截现有作品」模式是只读 +// (拿的是用户开发栈里的演示数据),仍按 `.app/dev-stack.json` 解析、不设护栏。 +const captureOnlyMode = + (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim() !== ''; +const target = resolveE2eTarget({ + scriptName: 'capture-game-lineage-visual.mjs', + writesFixtures: !captureOnlyMode, +}); +const API = target.apiBase; +const WEB = target.webBase; let checks = 0; let failures = 0; diff --git a/scripts/check-game-distribution-collection-e2e.mjs b/scripts/check-game-distribution-collection-e2e.mjs index ef4f6914a..88be41a09 100644 --- a/scripts/check-game-distribution-collection-e2e.mjs +++ b/scripts/check-game-distribution-collection-e2e.mjs @@ -35,11 +35,17 @@ // 即使共用同一个 Idempotency-Key 也不会互相命中」)相反;本脚本按后者(= 实测)断言。 import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; import JSZip from 'jszip'; +import { + cleanupRunFixtures, + fixtureTitle, + readDevStackMarker, + reportLeftoverFixtures, + resolveE2eTarget, +} from './e2e-target.mjs'; + const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; @@ -54,21 +60,13 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) { 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, ''); -if (!API) { - console.error( - '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' + - '或用 E2E_API_BASE 显式指定。', - ); - process.exit(2); -} +// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 +// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 +const devStack = readDevStackMarker(); +const target = resolveE2eTarget({ + scriptName: 'check-game-distribution-collection-e2e.mjs', +}); +const API = target.apiBase; let checks = 0; let failures = 0; @@ -397,6 +395,14 @@ async function main() { const suffix = String(stamp).slice(-6); const author = await register('132'); const viewer = await register('133'); + activeFixtureRun = { + api, + suffix, + owners: [ + { token: author.token, label: '作者' }, + { token: viewer.token, label: '查看者' }, + ], + }; check( '作者账号注册拿到 token', author.response.status === 200 && Boolean(author.token), @@ -411,7 +417,7 @@ async function main() { // ---------- fixture:已公开作品 X + 草稿作品 D ---------- const metadata = gameMetadata({ - title: `收藏作品 ${suffix}`, + title: fixtureTitle('收藏作品', suffix), coverAssetId: await uploadCover(author.token, stamp), }); const created = await createGame({ @@ -444,7 +450,7 @@ async function main() { ); const draftMetadata = gameMetadata({ - title: `收藏草稿 ${suffix}`, + title: fixtureTitle('收藏草稿', suffix), coverAssetId: await uploadCover(author.token, `${stamp}-draft`), }); const draftCreated = await createGame({ @@ -755,6 +761,25 @@ async function main() { `当前库内该用户收藏条数=${(await myCollections(author.token)).data?.games?.length ?? 'n/a'}。`, ); + // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 + const cleanup = await cleanupRunFixtures({ + api, + owners: [ + { token: author.token, label: '作者' }, + { token: viewer.token, label: '查看者' }, + ], + suffix, + }); + console.log( + `[collection-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + + (cleanup.failures.length > 0 + ? `,失败 ${cleanup.failures.length} 条(见下)` + : ''), + ); + for (const failure of cleanup.failures) { + console.log(`[collection-e2e] 清理失败 ${JSON.stringify(failure)}`); + } + console.log( `[collection-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); @@ -763,7 +788,13 @@ async function main() { } } -main().catch((error) => { +/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ +let activeFixtureRun = null; + +main().catch(async (error) => { console.error(`[collection-e2e] 未捕获异常:${error?.stack ?? error}`); + if (activeFixtureRun) { + await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); + } process.exitCode = 1; }); diff --git a/scripts/check-game-distribution-fork-authorization-e2e.mjs b/scripts/check-game-distribution-fork-authorization-e2e.mjs index 4aaf6e5c0..56b057d96 100644 --- a/scripts/check-game-distribution-fork-authorization-e2e.mjs +++ b/scripts/check-game-distribution-fork-authorization-e2e.mjs @@ -31,12 +31,19 @@ // `data.replayed` 与 `data.game.replayed`(既有写接口把 `replayed` 放在 data 顶层, // 例:modules/game_distribution.rs:682-700 的评价管理响应)。 -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 { + cleanupRunFixtures, + fixtureTitle, + readDevStackMarker, + reportLeftoverFixtures, + resolveE2eTarget, +} from './e2e-target.mjs'; + const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', @@ -74,26 +81,14 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) { } // 地址一律从 CWD 的 dev 栈状态文件读取,不写死端口(对齐 ratings-e2e.mjs:15-27)。 -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); -} +// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 +// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 +const devStack = readDevStackMarker(); +const target = resolveE2eTarget({ + scriptName: 'check-game-distribution-fork-authorization-e2e.mjs', +}); +const API = target.apiBase; +const WEB = target.webBase; let checks = 0; let failures = 0; @@ -606,6 +601,15 @@ async function main() { const stamp = Date.now(); const author = await register('132'); const other = await register('133'); + // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 + activeFixtureRun = { + api, + suffix: String(stamp).slice(-6), + owners: [ + { token: author.token, label: '作者' }, + { token: other.token, label: '其他作者' }, + ], + }; check( '作者注册拿到 token', author.response.status === 200 && Boolean(author.token), @@ -619,7 +623,7 @@ async function main() { if (!author.token || !other.token) process.exit(1); // 4) 创建作品(建游戏必须有封面:modules/game_distribution.rs:2806-2814) - const title = `共创授权 ${String(stamp).slice(-6)}`; + const title = fixtureTitle('共创授权', String(stamp).slice(-6)); const coverAssetId = await uploadCover(author.token, stamp); const created = await api('/api/game-distribution/games', { method: 'POST', @@ -774,7 +778,7 @@ async function main() { checkForkTier('最终档位(非法请求无副作用)', finalRead, 'full'); // 7) 浏览器视觉:需要一张 full 档作品(只读展示)与一张全新草稿作品(三态编辑器)。 - const draftTitle = `共创授权草稿 ${String(stamp).slice(-6)}`; + const draftTitle = fixtureTitle('共创授权草稿', String(stamp).slice(-6)); const draftCoverAssetId = await uploadCover(author.token, `${stamp}-draft`); const draftCreated = await api('/api/game-distribution/games', { method: 'POST', @@ -790,6 +794,25 @@ async function main() { await runBrowserStep({ phone: author.phone, title, draftTitle }); + // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 + const cleanup = await cleanupRunFixtures({ + api, + suffix: String(stamp).slice(-6), + owners: [ + { token: author.token, label: '作者' }, + { token: other.token, label: '其他作者' }, + ], + }); + console.log( + `[fork-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + + (cleanup.failures.length > 0 + ? `,失败 ${cleanup.failures.length} 条(见下)` + : ''), + ); + for (const failure of cleanup.failures) { + console.log(`[fork-e2e] 清理失败 ${JSON.stringify(failure)}`); + } + console.log( `[fork-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); @@ -798,7 +821,13 @@ async function main() { } } -main().catch((error) => { +/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ +let activeFixtureRun = null; + +main().catch(async (error) => { console.error(`[fork-e2e] 未捕获异常:${error?.stack ?? error}`); + if (activeFixtureRun) { + await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); + } process.exitCode = 1; }); diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs index bec73e0e3..b996c49a2 100644 --- a/scripts/check-game-distribution-lineage-e2e.mjs +++ b/scripts/check-game-distribution-lineage-e2e.mjs @@ -58,6 +58,14 @@ import path from 'node:path'; import JSZip from 'jszip'; +import { + cleanupRunFixtures, + fixtureTitle, + readDevStackMarker, + reportLeftoverFixtures, + resolveE2eTarget, +} from './e2e-target.mjs'; + const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', @@ -123,26 +131,14 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) { 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); -} +// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 +// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 +const devStack = readDevStackMarker(); +const target = resolveE2eTarget({ + scriptName: 'check-game-distribution-lineage-e2e.mjs', +}); +const API = target.apiBase; +const WEB = target.webBase; let checks = 0; let failures = 0; @@ -1007,6 +1003,15 @@ async function main() { const suffix = String(stamp).slice(-6); const author = await register('132'); const forker = await register('133'); + // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 + activeFixtureRun = { + api, + suffix, + owners: [ + { token: author.token, label: '母版作者' }, + { token: forker.token, label: '改编者' }, + ], + }; check( '母版作者注册拿到 token', author.response.status === 200 && Boolean(author.token), @@ -1030,9 +1035,9 @@ async function main() { ); // A 段:404 规则(防泄露回归网) - const parentTitle = `族谱母版 ${suffix}`; - const childTitle = `族谱改编 ${suffix}`; - const blockedTitle = `族谱禁改编 ${suffix}`; + const parentTitle = fixtureTitle('族谱母版', suffix); + const childTitle = fixtureTitle('族谱改编', suffix); + const blockedTitle = fixtureTitle('族谱禁改编', suffix); const parentCover = await uploadCover(author.token, `${stamp}-parent`); const parentCreated = await createGame({ token: author.token, @@ -1411,14 +1416,14 @@ async function main() { const richRoot = await createPublishedNode({ label: 'R', - title: `富树母版 ${suffix}`, + title: fixtureTitle('富树母版', suffix), owner: author, ownerLabel: '作者甲', promoteTo: 'nonCommercial', }); const richB1 = await createPublishedNode({ label: 'B1', - title: `富树分支 B1 ${suffix}`, + title: fixtureTitle('富树分支 B1', suffix), owner: author, ownerLabel: '作者甲', parent: richRoot, @@ -1426,7 +1431,7 @@ async function main() { }); const richC1 = await createPublishedNode({ label: 'C1', - title: `富树分支 C1 ${suffix}`, + title: fixtureTitle('富树分支 C1', suffix), owner: forker, ownerLabel: '作者乙', parent: richRoot, @@ -1434,7 +1439,7 @@ async function main() { }); const richF1 = await createPublishedNode({ label: 'F1', - title: `富树分支 F1 ${suffix}`, + title: fixtureTitle('富树分支 F1', suffix), owner: forker, ownerLabel: '作者乙', parent: richRoot, @@ -1442,7 +1447,7 @@ async function main() { }); const richD = await createPublishedNode({ label: 'D', - title: `富树二代 D ${suffix}`, + title: fixtureTitle('富树二代 D', suffix), owner: author, ownerLabel: '作者甲', parent: richB1, @@ -1450,7 +1455,7 @@ async function main() { }); const richE = await createPublishedNode({ label: 'E', - title: `富树三代 E ${suffix}`, + title: fixtureTitle('富树三代 E', suffix), owner: forker, ownerLabel: '作者乙', parent: richD, @@ -1998,6 +2003,25 @@ async function main() { '本脚本只断言小树 truncated=false + 源码常量 200。若要真验截断,建议在 module-game-distribution 单测层做(已有 truncation 单测)。', ); + // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 + const cleanup = await cleanupRunFixtures({ + api, + suffix, + owners: [ + { token: author.token, label: '母版作者' }, + { token: forker.token, label: '改编者' }, + ], + }); + console.log( + `[lineage-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + + (cleanup.failures.length > 0 + ? `,失败 ${cleanup.failures.length} 条(见下)` + : ''), + ); + for (const failure of cleanup.failures) { + console.log(`[lineage-e2e] 清理失败 ${JSON.stringify(failure)}`); + } + console.log( `[lineage-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); @@ -2006,7 +2030,13 @@ async function main() { } } -main().catch((error) => { +/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ +let activeFixtureRun = null; + +main().catch(async (error) => { console.error(`[lineage-e2e] 未捕获异常:${error?.stack ?? error}`); + if (activeFixtureRun) { + await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); + } process.exitCode = 1; }); diff --git a/scripts/check-game-distribution-project-bundle-e2e.mjs b/scripts/check-game-distribution-project-bundle-e2e.mjs index f3ca9d8b0..1aeb7f6c6 100644 --- a/scripts/check-game-distribution-project-bundle-e2e.mjs +++ b/scripts/check-game-distribution-project-bundle-e2e.mjs @@ -38,11 +38,17 @@ // (load_owner_version_or_404:非 owner 按「不存在」处理,与发行包上行族同口径)。 import { createHash, randomBytes } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; import JSZip from 'jszip'; +import { + cleanupRunFixtures, + fixtureTitle, + readDevStackMarker, + reportLeftoverFixtures, + resolveE2eTarget, +} from './e2e-target.mjs'; + const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; @@ -64,21 +70,13 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) { 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, ''); -if (!API) { - console.error( - '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' + - '或用 E2E_API_BASE 显式指定。', - ); - process.exit(2); -} +// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 +// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 +const devStack = readDevStackMarker(); +const target = resolveE2eTarget({ + scriptName: 'check-game-distribution-project-bundle-e2e.mjs', +}); +const API = target.apiBase; let checks = 0; let failures = 0; @@ -602,6 +600,15 @@ async function main() { stampRef.value = stamp; const author = await register('132'); const other = await register('133'); + // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 + activeFixtureRun = { + api, + suffix, + owners: [ + { token: author.token, label: '作者' }, + { token: other.token, label: '其他作者' }, + ], + }; check( '作者注册拿到 token', author.response.status === 200 && Boolean(author.token), @@ -616,7 +623,7 @@ async function main() { authorTokenRef.token = author.token; // ---------- fixture:作品 A ---------- - const gameTitle = `工程源包 ${suffix}`; + const gameTitle = fixtureTitle('工程源包', suffix); const coverAssetId = await uploadCover(author.token, stamp); const metadata = gameMetadata({ title: gameTitle, coverAssetId }); const created = await createGame({ @@ -1170,7 +1177,7 @@ async function main() { ); // ---------- B9:对照(无工程包的公开作品回落成品包) ---------- - const controlTitle = `工程源包对照 ${suffix}`; + const controlTitle = fixtureTitle('工程源包对照', suffix); const controlCover = await uploadCover(author.token, `${stamp}-control`); const controlMeta = gameMetadata({ title: controlTitle, @@ -1250,6 +1257,25 @@ async function main() { `status=${controlProjectDownload.status}`, ); + // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 + const cleanup = await cleanupRunFixtures({ + api, + suffix, + owners: [ + { token: author.token, label: '作者' }, + { token: other.token, label: '其他作者' }, + ], + }); + console.log( + `[project-bundle-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + + (cleanup.failures.length > 0 + ? `,失败 ${cleanup.failures.length} 条(见下)` + : ''), + ); + for (const failure of cleanup.failures) { + console.log(`[project-bundle-e2e] 清理失败 ${JSON.stringify(failure)}`); + } + console.log( `[project-bundle-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); @@ -1258,7 +1284,13 @@ async function main() { } } -main().catch((error) => { +/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ +let activeFixtureRun = null; + +main().catch(async (error) => { console.error(`[project-bundle-e2e] 未捕获异常:${error?.stack ?? error}`); + if (activeFixtureRun) { + await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); + } process.exitCode = 1; }); diff --git a/scripts/check-game-distribution-theme-e2e.mjs b/scripts/check-game-distribution-theme-e2e.mjs index ea1422906..4a03f69a6 100644 --- a/scripts/check-game-distribution-theme-e2e.mjs +++ b/scripts/check-game-distribution-theme-e2e.mjs @@ -34,11 +34,17 @@ // - 改编声明(fork 字段)+ 公开版本 id 取法:lineage 脚本里取 `versions.find(status==='published')` 那一段 import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; import JSZip from 'jszip'; +import { + cleanupRunFixtures, + fixtureTitle, + readDevStackMarker, + reportLeftoverFixtures, + resolveE2eTarget, +} from './e2e-target.mjs'; + const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; @@ -53,21 +59,13 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) { 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, ''); -if (!API) { - console.error( - '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' + - '或用 E2E_API_BASE 显式指定。', - ); - process.exit(2); -} +// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 +// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 +const devStack = readDevStackMarker(); +const target = resolveE2eTarget({ + scriptName: 'check-game-distribution-theme-e2e.mjs', +}); +const API = target.apiBase; let checks = 0; let failures = 0; @@ -469,6 +467,15 @@ async function main() { const suffix = String(stamp).slice(-6); const author = await register('132'); const stranger = await register('133'); + // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 + activeFixtureRun = { + api, + suffix, + owners: [ + { token: author.token, label: '作者' }, + { token: stranger.token, label: '陌生作者' }, + ], + }; check( '作者注册拿到 token', author.response.status === 200 && Boolean(author.token), @@ -483,7 +490,7 @@ async function main() { // ---------- fixture:根作品 R(公开)+ 衍生作品 C(公开)+ 第二个根 R2 ---------- const rootMeta = gameMetadata({ - title: `主题根作品 ${suffix}`, + title: fixtureTitle('主题根作品', suffix), coverAssetId: await uploadCover(author.token, `${stamp}-root`), }); const rootCreated = await createGame({ @@ -539,7 +546,7 @@ async function main() { rootVersions.find((version) => version.status === 'published')?.versionId ?? ''; const childMeta = gameMetadata({ - title: `主题衍生作品 ${suffix}`, + title: fixtureTitle('主题衍生作品', suffix), coverAssetId: await uploadCover(author.token, `${stamp}-child`), }); const childCreated = await createGame({ @@ -575,7 +582,7 @@ async function main() { ); const secondRootMeta = gameMetadata({ - title: `主题第二个根 ${suffix}`, + title: fixtureTitle('主题第二个根', suffix), coverAssetId: await uploadCover(author.token, `${stamp}-root2`), }); const secondRootCreated = await createGame({ @@ -1232,7 +1239,7 @@ async function main() { const bulkRootIds = []; let reuseNoted = false; for (let index = 0; index < 51; index += 1) { - const title = `批量成员根作品 ${suffix}-${index}`; + const title = fixtureTitle('批量成员根作品', `${suffix}-${index}`); let created = await createGame({ token: author.token, metadata: gameMetadata({ title, coverAssetId: reusableCoverAssetId }), @@ -1306,7 +1313,7 @@ async function main() { const softDeletedRoot = await createGame({ token: author.token, metadata: gameMetadata({ - title: `软删除成员 ${suffix}`, + title: fixtureTitle('软删除成员', suffix), coverAssetId: await uploadCover(author.token, `${stamp}-soft`), }), idemKey: `theme-soft-root-${stamp}`, @@ -1504,6 +1511,25 @@ async function main() { '分页排序键(created_at 倒序 + themeId 兜底)只通过「不重不漏」间接验证,未构造同微秒创建的主题对。', ); + // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 + const cleanup = await cleanupRunFixtures({ + api, + suffix, + owners: [ + { token: author.token, label: '作者' }, + { token: stranger.token, label: '陌生作者' }, + ], + }); + console.log( + `[theme-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + + (cleanup.failures.length > 0 + ? `,失败 ${cleanup.failures.length} 条(见下)` + : ''), + ); + for (const failure of cleanup.failures) { + console.log(`[theme-e2e] 清理失败 ${JSON.stringify(failure)}`); + } + console.log( `[theme-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); @@ -1512,7 +1538,13 @@ async function main() { } } -main().catch((error) => { +/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ +let activeFixtureRun = null; + +main().catch(async (error) => { console.error(`[theme-e2e] 未捕获异常:${error?.stack ?? error}`); + if (activeFixtureRun) { + await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); + } process.exitCode = 1; }); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index aae03d3b2..042052ed4 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -435,6 +435,15 @@ function normalizeServiceName(rawName) { } function resolveDevStackStatePath(root = repoRoot) { + // 状态文件默认写在仓库根的 `.app/dev-stack.json`。允许用环境变量把**这一份**栈的状态文件 + // 指到别处(隔离 e2e 栈用):否则隔离栈会覆盖用户正在用的标记,让用户的 dev 栈误以为自己 + // 被替换而自行退出(2026-10-07 实测踩到)。 + const override = String( + process.env.GENARRATIVE_DEV_STACK_STATE_PATH ?? '', + ).trim(); + if (override) { + return resolve(override); + } return join(root, '.app/dev-stack.json'); } @@ -1234,6 +1243,10 @@ function stopWindowsWorktreeBackendProcesses({ if (process.platform !== 'win32') { return []; } + if (isIsolatedDevStackRun()) { + // 同上:隔离栈不做按 exe 路径的本工作树清扫,避免误杀用户栈(两者共用同一个二进制路径)。 + return []; + } const stopped = stopWindowsWorktreeProcesses({ apiServerExePath: resolveWindowsApiServerExePath(), @@ -2564,10 +2577,22 @@ class DevRunner { } } +function isIsolatedDevStackRun() { + // 隔离栈(e2e 用):状态文件被指到临时目录。它的清扫动作不能碰**用户正在跑的那套栈**—— + // 用户栈与本栈跑的是同一个 `server-rs/target/debug/api-server.exe`,按 exe 路径扫会误杀。 + return Boolean( + String(process.env.GENARRATIVE_DEV_STACK_STATE_PATH ?? '').trim(), + ); +} + function stopExistingWindowsApiServer(logStream) { if (process.platform !== 'win32') { return; } + if (isIsolatedDevStackRun()) { + // 隔离栈不做「按 exe 路径清旧 api-server」的兜底:它没有权限判断哪些进程属于自己。 + return; + } const apiServerExePath = resolveWindowsApiServerExePath(); const snapshot = readWindowsProcessSnapshot(); diff --git a/scripts/e2e-stack.mjs b/scripts/e2e-stack.mjs new file mode 100644 index 000000000..3fc5bf118 --- /dev/null +++ b/scripts/e2e-stack.mjs @@ -0,0 +1,188 @@ +// 一次性隔离 e2e 栈:用**专用端口 + 专用数据库 + 临时 data-dir**拉起 dev.mjs,跑完把栈停掉、临时目录删掉。 +// +// 为什么需要它:e2e 脚本会造夹具,跑在用户正在用的开发栈上会把用户的库灌脏(2026-10-07 盘点: +// `genarrative-game-creator-dev` 里有 92 条夹具)。隔离栈与用户的栈**不共享数据库、不共享 +// data-dir、端口也不重叠**,因此可以随便造。 +// +// 用法(等价 npm script:`npm run e2e:run -- ` / `npm run e2e:stack`): +// node scripts/e2e-stack.mjs --suite scripts/check-game-distribution-lineage-e2e.mjs +// node scripts/e2e-stack.mjs # 只起栈(前台长驻,Ctrl-C 结束) +// 选项: +// --suite 起栈就绪后顺序跑一个或多个(可重复)suite,跑完自动停栈 +// --stack-only 只起栈(前台),不跑 suite +// --keep-data 结束后保留临时 data-dir(默认删除) +// --keep-stack 跑完 suite 也不停栈(供人工继续看) +// +// 端口/库名与 `scripts/e2e-target.mjs` 的 `E2E_ISOLATED_STACK` 一致(单一事实来源)。 + +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { E2E_ISOLATED_STACK } from './e2e-target.mjs'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const argv = process.argv.slice(2); +const readFlagValues = (flag) => { + const values = []; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === flag && argv[index + 1]) values.push(argv[index + 1]); + } + return values; +}; +const suites = readFlagValues('--suite'); +const stackOnly = argv.includes('--stack-only') || suites.length === 0; +const keepData = argv.includes('--keep-data'); +const keepStack = argv.includes('--keep-stack'); + +const dataDir = mkdtempSync(path.join(tmpdir(), 'genarrative-e2e-spacetime-')); +console.log(`[e2e-stack] 隔离栈数据目录 ${dataDir}`); + +const devArgs = [ + 'scripts/dev.mjs', + '--database', + E2E_ISOLATED_STACK.database, + '--spacetime-data-dir', + dataDir, + '--api-port', + String(E2E_ISOLATED_STACK.apiPort), + '--bgfilter-worker-port', + String(E2E_ISOLATED_STACK.bgfilterWorkerPort), + '--spacetime-port', + String(E2E_ISOLATED_STACK.spacetimePort), + '--admin-web-port', + String(E2E_ISOLATED_STACK.adminWebPort), + '--web-port', + String(E2E_ISOLATED_STACK.webPort), + '--no-interactive', +]; + +const stack = spawn(process.execPath, devArgs, { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + // **关键**:隔离栈把自己的 dev-stack 标记写到临时目录,绝不覆盖仓库根的 `.app/dev-stack.json`, + // 否则用户正在跑的那套 dev 栈会以为自己的标记被替换而自行退出(2026-10-07 实测踩到)。 + GENARRATIVE_DEV_STACK_STATE_PATH: path.join(dataDir, 'dev-stack.json'), + GENARRATIVE_ADMIN_USERNAME: + process.env.GENARRATIVE_ADMIN_USERNAME ?? + process.env.E2E_ADMIN_USER ?? + 'admin', + GENARRATIVE_ADMIN_PASSWORD: + process.env.GENARRATIVE_ADMIN_PASSWORD ?? + process.env.E2E_ADMIN_PASSWORD ?? + '123456', + }, +}); + +let stopping = false; +const stopStack = async () => { + if (stopping || keepStack) return; + stopping = true; + await new Promise((resolve) => { + if (stack.exitCode !== null || stack.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + stack.kill('SIGKILL'); + resolve(); + }, 15_000); + stack.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + stack.kill('SIGTERM'); + }); +}; +const cleanupDataDir = () => { + if (keepData) { + console.log(`[e2e-stack] 按 --keep-data 保留 ${dataDir}`); + return; + } + try { + rmSync(dataDir, { recursive: true, force: true }); + console.log(`[e2e-stack] 已删除临时数据目录 ${dataDir}`); + } catch (error) { + console.log(`[e2e-stack] 删除临时目录失败(可手工清理):${error.message}`); + } +}; + +async function waitForReady(timeoutMs = 300_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (stack.exitCode !== null || stack.signalCode !== null) { + throw new Error( + `dev 栈提前退出:code=${stack.exitCode} signal=${stack.signalCode}`, + ); + } + try { + const response = await fetch( + `http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}/healthz`, + { signal: AbortSignal.timeout(2_000) }, + ); + if (response.status === 200) return; + } catch { + // 未就绪:继续等 + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + throw new Error('等待隔离栈 api-server /healthz 超时'); +} + +async function runSuite(scriptPath) { + console.log(`[e2e-stack] 运行 ${scriptPath}`); + const child = spawn(process.execPath, [scriptPath], { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + E2E_API_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}`, + E2E_WEB_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.webPort}`, + }, + }); + const code = await new Promise((resolve) => child.once('exit', resolve)); + return code ?? 1; +} + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + void (async () => { + await stopStack(); + cleanupDataDir(); + process.exit(130); + })(); + }); +} + +try { + await waitForReady(); + console.log( + `[e2e-stack] 隔离栈就绪:api=http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort} web=http://127.0.0.1:${E2E_ISOLATED_STACK.webPort} 库=${E2E_ISOLATED_STACK.database}`, + ); + if (stackOnly) { + console.log( + '[e2e-stack] 只起栈模式:另一个终端里用 E2E_API_BASE/E2E_WEB_BASE 指向上面的地址跑脚本;Ctrl-C 结束。', + ); + } else { + let exitCode = 0; + for (const suite of suites) { + const code = await runSuite(suite); + if (code !== 0) exitCode = code; + } + await stopStack(); + cleanupDataDir(); + process.exit(exitCode); + } +} catch (error) { + console.error(`[e2e-stack] ${error.message}`); + await stopStack(); + cleanupDataDir(); + process.exit(1); +} diff --git a/scripts/e2e-target.mjs b/scripts/e2e-target.mjs new file mode 100644 index 000000000..f6758ff25 --- /dev/null +++ b/scripts/e2e-target.mjs @@ -0,0 +1,237 @@ +// e2e 夹具隔离支持:目标解析 + 护栏 + 夹具标题前缀 + 跑批后自清理。 +// +// 为什么需要它:2026-10-07 盘点发现,本地 e2e 脚本缺省会跟着 `.app/dev-stack.json` 打**用户正在用的 +// 开发栈**(`npm run dev` 的那套库),每个脚本每跑一轮就往用户的库里塞十几到几十条夹具 +// (`genarrative-game-creator-dev` 一度有 105 条 = 13 演示 + 92 夹具)。本模块把口径改成: +// +// 1. **默认打隔离栈**:夹具类脚本在未显式给 `E2E_API_BASE` 时使用 8188/8189/3110/3111/3112 这套 +// 专用端口(由 `npm run e2e:stack` 拉起,配套 `--database genarrative-game-creator-e2e` 与 +// 临时 data-dir),不再回落到 dev 栈标记里的 api-server。 +// 2. **护栏**:解析出的目标若与 `.app/dev-stack.json` 记录的 api-server **同源或同端口**, +// 夹具类脚本**直接拒绝启动**(exit 2),除非显式 `E2E_ALLOW_DEV_STACK=1`。 +// 3. **夹具前缀**:所有夹具标题统一带 `E2E·` 前缀(`fixtureTitle()`),盘点/清理一眼可辨, +// 不再依赖随机数字后缀。 +// 4. **跑批后自清理**:`cleanupRunFixtures()` 用本次跑批的 owner token + 标题前缀把本轮夹具 +// 软删(owner 维度 DELETE);清理失败或脚本中断时用 `reportLeftoverFixtures()` 打印待清理清单。 +// +// 只读类脚本(例如截图工具的「只截现有作品」模式)应当传 `writesFixtures: false`:它们没有理由 +// 拒打 dev 栈,默认仍跟着 `.app/dev-stack.json`。 + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** 专用隔离栈(与 `npm run e2e:stack` 的端口/data-dir 一一对应)。 */ +export const E2E_ISOLATED_STACK = { + apiPort: 8188, + bgfilterWorkerPort: 8189, + spacetimePort: 3110, + adminWebPort: 3111, + webPort: 3112, + apiBase: 'http://127.0.0.1:8188', + webBase: 'http://127.0.0.1:3112', + database: 'genarrative-game-creator-e2e', +}; + +/** 夹具标题统一前缀:盘点/清理按它一眼区分(不要只靠随机后缀)。 */ +export const FIXTURE_TITLE_PREFIX = 'E2E·'; + +/** 夹具标题:`E2E·<标签> <后缀>`。 */ +export function fixtureTitle(label, suffix) { + return `${FIXTURE_TITLE_PREFIX}${label} ${suffix}`; +} + +function normalizeBase(value) { + return String(value ?? '') + .trim() + .replace(/\/+$/u, ''); +} + +function readDevStackMarker(cwd = process.cwd()) { + try { + return JSON.parse( + readFileSync(path.resolve(cwd, '.app/dev-stack.json'), 'utf8'), + ); + } catch { + return null; + } +} + +/** 容错读取 dev-stack 标记(不存在/坏 JSON 都回 null):只给日志与护栏判定用。 */ +export { readDevStackMarker }; + +function portOf(value) { + try { + return new URL(normalizeBase(value)).port; + } catch { + return ''; + } +} + +/** 解析出的目标是否就是 `.app/dev-stack.json` 记录的那套栈(同地址或同端口都算)。 */ +export function isDevStackTarget(apiBase, marker) { + const devApi = normalizeBase(marker?.services?.['api-server']?.url); + if (!devApi) return false; + const target = normalizeBase(apiBase); + if (target === devApi) return true; + const targetPort = portOf(target); + return Boolean(targetPort) && targetPort === portOf(devApi); +} + +function devStackGuardMessage({ scriptName, apiBase, devApi }) { + return [ + `${scriptName}: 目标 ${apiBase} 就是用户正在用的开发栈(.app/dev-stack.json → ${devApi})。`, + '夹具类 e2e 缺省应打隔离栈,避免把夹具写进用户的库。二选一:', + ` 1) 用隔离栈:npm run e2e:stack(缺省 api-server http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort},库 ${E2E_ISOLATED_STACK.database})`, + ' 2) 明确越权:E2E_API_BASE= E2E_ALLOW_DEV_STACK=1(这会真的写进用户的库)', + ].join('\n'); +} + +/** + * 解析本次跑批的目标地址。 + * + * @param {object} options + * @param {string} options.scriptName 打印提示用 + * @param {boolean} [options.writesFixtures] 本脚本是否会往目标栈写夹具(默认 true) + * @param {boolean} [options.allowIsolatedFallback] 夹具类脚本在未显式给 E2E_API_BASE 时用隔离栈(默认 true) + * @returns {{ apiBase: string, webBase: string, devStackApi: string, devStackWeb: string, allowDevStackOverride: boolean }} + */ +export function resolveE2eTarget({ + scriptName, + writesFixtures = true, + cwd = process.cwd(), +} = {}) { + const marker = readDevStackMarker(cwd); + const devStackApi = normalizeBase(marker?.services?.['api-server']?.url); + const devStackWeb = normalizeBase(marker?.services?.web?.url); + const allowDevStackOverride = + (process.env.E2E_ALLOW_DEV_STACK ?? '').trim() === '1'; + const explicitApi = normalizeBase(process.env.E2E_API_BASE); + const explicitWeb = normalizeBase(process.env.E2E_WEB_BASE); + + const fallbackApi = writesFixtures + ? E2E_ISOLATED_STACK.apiBase + : devStackApi || E2E_ISOLATED_STACK.apiBase; + const fallbackWeb = writesFixtures + ? E2E_ISOLATED_STACK.webBase + : devStackWeb || E2E_ISOLATED_STACK.webBase; + + const apiBase = explicitApi || fallbackApi; + const webBase = explicitWeb || fallbackWeb; + + if ( + writesFixtures && + !allowDevStackOverride && + isDevStackTarget(apiBase, marker) + ) { + console.error( + devStackGuardMessage({ scriptName, apiBase, devApi: devStackApi }), + ); + process.exit(2); + } + + return { + apiBase, + webBase, + devStackApi, + devStackWeb, + allowDevStackOverride, + writesFixtures, + }; +} + +/** + * 跑批后自清理:把本轮夹具(owner 视角里标题以 `E2E·` 开头且含本次后缀的那些)逐条软删。 + * + * 只删**自己账号名下、标题命中本轮后缀**的作品:不碰演示数据,也不碰别人造的东西。 + * 清理失败不抛异常,改为返回失败清单,由调用方打印。 + * + * @param {object} options + * @param {(pathname: string, options?: object) => Promise} options.api 各脚本自带的 api()(带信封/鉴权) + * @param {Array<{ token: string, label: string }>} options.owners 本轮用到的 owner token(作者 / 分叉者) + * @param {string} options.suffix 本轮后缀(`String(Date.now()).slice(-6)`) + * @param {string} [options.titlePrefix] 夹具前缀(默认 `E2E·`) + */ +export async function cleanupRunFixtures({ + api, + owners, + suffix, + titlePrefix = FIXTURE_TITLE_PREFIX, +}) { + const failures = []; + const deleted = []; + for (const owner of owners) { + if (!owner?.token) continue; + const list = await api('/api/game-distribution/my-games', { + token: owner.token, + }); + if (list.status !== 200) { + failures.push({ + owner: owner.label, + reason: `列我的作品失败 status=${list.status}`, + }); + continue; + } + const mine = (list.data?.games ?? []).filter( + (game) => + typeof game.title === 'string' && + game.title.startsWith(titlePrefix) && + game.title.includes(suffix), + ); + for (const game of mine) { + const revision = game.publicationRevision ?? 0; + const response = await api( + `/api/game-distribution/my-games/${game.id}?expectedPublicationRevision=${revision}`, + { method: 'DELETE', token: owner.token }, + ); + if (response.status === 200) { + deleted.push({ owner: owner.label, id: game.id, title: game.title }); + } else { + failures.push({ + owner: owner.label, + id: game.id, + title: game.title, + reason: `status=${response.status} ${response.error?.code ?? ''}`, + }); + } + } + } + return { deleted, failures }; +} + +/** 中断/失败路径:把「本轮还没清掉的夹具」打印出来,便于人工补删。 */ +export async function reportLeftoverFixtures({ + api, + owners, + suffix, + titlePrefix = FIXTURE_TITLE_PREFIX, + log = console.log, +}) { + const leftovers = []; + for (const owner of owners) { + if (!owner?.token) continue; + const list = await api('/api/game-distribution/my-games', { + token: owner.token, + }); + if (list.status !== 200) continue; + for (const game of list.data?.games ?? []) { + if ( + typeof game.title === 'string' && + game.title.startsWith(titlePrefix) && + game.title.includes(suffix) + ) { + leftovers.push({ owner: owner.label, id: game.id, title: game.title }); + } + } + } + if (leftovers.length === 0) { + log(`[e2e-fixture] 本轮(后缀 ${suffix})在目标库里没有残留夹具`); + return leftovers; + } + log( + `[e2e-fixture] 本轮(后缀 ${suffix})仍有 ${leftovers.length} 条夹具未清理,可手工软删:`, + ); + for (const entry of leftovers) { + log(` - ${entry.owner} ${entry.id} ${entry.title}`); + } + return leftovers; +} diff --git a/scripts/seed-game-distribution-co-creation.mjs b/scripts/seed-game-distribution-co-creation.mjs index b00beaa2b..55bf5dd0c 100644 --- a/scripts/seed-game-distribution-co-creation.mjs +++ b/scripts/seed-game-distribution-co-creation.mjs @@ -41,6 +41,8 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) { process.exit(2); } +// 目标地址:本脚本**有意**写在用户的开发栈上(它就是「把演示数据灌进 dev 栈」的工具,幂等、判重不绑账号), +// 因此不受 `e2e-target.mjs` 的「夹具类脚本禁止打用户栈」护栏约束;要写到别处请显式给 E2E_API_BASE。 const devStack = JSON.parse( readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'), );