238 lines
8.6 KiB
JavaScript
238 lines
8.6 KiB
JavaScript
// 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;
|
||
}
|