Files
Genarrative/scripts/e2e-target.mjs
T

238 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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=<dev 栈地址> 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<any>} 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;
}