// 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; }