合并 origin/master:待发消息队列归宿主叠加 master 的 active-turns 广播与保活下沉
- direct_runtime/mod.rs:保留本分支「Thread Manager 是活动回合唯一事实源」(direct_active_turn_id_at), 丢掉 master 的 DirectTaonierActiveInvocationGuard 第二份进程内表;master 的美术背景路由改动全部保留 - direct_thread_manager.rs:双方都保留——本分支的待发队列 / 容量上限 / 残留回合兜底释放, 与 master 的 emit_direct_active_turns_changed 广播、unsubscribe、update_active_turn 返回「是否真的变了」 - useDirectProjectChatController.ts:保留本分支入队化逻辑,采纳 master 的 rawMessage 小重构与保活下沉 - direct_turn_dispatch.rs:会话保活改挂放行占用(DirectTurnReservation._session_keepalive), 随占用释放即停,等价承接 master 放在调用身份守卫里的保活意图 - 保留本分支对 direct-execution-production-fixture.mjs 的删除(--direct-codex-chat CLI 已退役,夹具无入口) - 连带退役 scripts/run-agc-direct-execution-fixture.mjs 与 npm run check:agc-direct-execution-fixture(只服务该夹具) - 文档同步:ADR / 09-24 实施计划 / decision-log 记录上述退役,09-16 与 09-18 里程碑、pitfalls 的夹具命令标注为已退役
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
// AGC 项目快照「后台列表 + 工程 ZIP 下载」的真实链路检查。
|
||||
//
|
||||
// 用法(需要本地 dev 栈与一份已经同步过的项目副本):
|
||||
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
|
||||
// E2E_SNAPSHOT_USER_ID=<快照归属 user id> E2E_SNAPSHOT_PROJECT_ID=<project id> \
|
||||
// E2E_SNAPSHOT_PROJECT_DIR=<本地项目副本绝对路径> \
|
||||
// npm run check:agc-project-snapshot-admin-http
|
||||
// E2E_API_BASE 默认 http://127.0.0.1:4198;E2E_SNAPSHOT_CHANNEL 默认 dev。
|
||||
//
|
||||
// 覆盖:管理员列表能看到该快照的统计(status=ready / fileCount / totalBytes)→ 下载 ZIP →
|
||||
// 条目数与列表一致 → 每个条目与本地副本同名文件的 SHA-256 逐一致 → ZIP 里没有版本库 /
|
||||
// node_modules / .env* 这类被排除的内容。整条链路只读:不写 OSS、不改后台数据。
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
|
||||
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 SNAPSHOT_USER_ID = (process.env.E2E_SNAPSHOT_USER_ID ?? '').trim();
|
||||
const SNAPSHOT_PROJECT_ID = (process.env.E2E_SNAPSHOT_PROJECT_ID ?? '').trim();
|
||||
const PROJECT_DIR = (process.env.E2E_SNAPSHOT_PROJECT_DIR ?? '').trim();
|
||||
const CHANNEL = (process.env.E2E_SNAPSHOT_CHANNEL ?? 'dev').trim();
|
||||
|
||||
for (const [name, value] of [
|
||||
['E2E_ADMIN_USER', ADMIN_USER],
|
||||
['E2E_ADMIN_PASSWORD', ADMIN_PASSWORD],
|
||||
['E2E_SNAPSHOT_USER_ID', SNAPSHOT_USER_ID],
|
||||
['E2E_SNAPSHOT_PROJECT_ID', SNAPSHOT_PROJECT_ID],
|
||||
['E2E_SNAPSHOT_PROJECT_DIR', PROJECT_DIR],
|
||||
]) {
|
||||
if (!value) {
|
||||
console.error(
|
||||
`缺少 ${name}:本脚本要对已同步的快照做列表与 ZIP 下载核对。`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, raw = false } = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
if (body !== undefined) {
|
||||
finalHeaders['Content-Type'] = 'application/json';
|
||||
finalBody = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(`${API}${pathname}`, {
|
||||
method,
|
||||
headers: finalHeaders,
|
||||
body: finalBody,
|
||||
});
|
||||
if (raw) {
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
json,
|
||||
text,
|
||||
data: json?.data,
|
||||
error: json?.error,
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const login = await api('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
|
||||
});
|
||||
const admin = login.data?.token ?? login.data?.accessToken;
|
||||
check(
|
||||
'管理员登录成功',
|
||||
login.status === 200 && Boolean(admin),
|
||||
`status=${login.status}`,
|
||||
);
|
||||
if (!admin) process.exit(1);
|
||||
|
||||
const list = await api(
|
||||
`/admin/api/project-snapshots?channel=${encodeURIComponent(CHANNEL)}&limit=100`,
|
||||
{ token: admin },
|
||||
);
|
||||
const item = (list.data?.items ?? []).find(
|
||||
(entry) =>
|
||||
entry.userId === SNAPSHOT_USER_ID &&
|
||||
entry.projectId === SNAPSHOT_PROJECT_ID,
|
||||
);
|
||||
check(
|
||||
'后台列表包含该项目的快照',
|
||||
list.status === 200 && Boolean(item),
|
||||
`status=${list.status} items=${list.data?.items?.length ?? 'n/a'}`,
|
||||
);
|
||||
check(
|
||||
'快照统计是 ready 且文件数与体积非零',
|
||||
item?.status === 'ready' && item.fileCount > 0 && item.totalBytes > 0,
|
||||
`status=${item?.status ?? ''} files=${item?.fileCount ?? 'n/a'} bytes=${item?.totalBytes ?? 'n/a'}`,
|
||||
);
|
||||
if (!item) process.exit(1);
|
||||
|
||||
const download = await api(
|
||||
`/admin/api/project-snapshots/${encodeURIComponent(SNAPSHOT_USER_ID)}/${encodeURIComponent(SNAPSHOT_PROJECT_ID)}/download?channel=${encodeURIComponent(CHANNEL)}`,
|
||||
{ token: admin, raw: true },
|
||||
);
|
||||
check(
|
||||
'后台下载返回 ZIP',
|
||||
download.status === 200 &&
|
||||
(download.headers.get('content-type') ?? '').includes('zip'),
|
||||
`status=${download.status} type=${download.headers.get('content-type') ?? ''} bytes=${download.buffer.length}`,
|
||||
);
|
||||
if (download.status !== 200) process.exit(1);
|
||||
|
||||
const archive = await JSZip.loadAsync(download.buffer);
|
||||
const entries = Object.values(archive.files).filter((file) => !file.dir);
|
||||
check(
|
||||
'ZIP 条目数与后台列表的文件数一致',
|
||||
entries.length === item.fileCount,
|
||||
`zip=${entries.length} list=${item.fileCount}`,
|
||||
);
|
||||
const zipBytes = entries.reduce(
|
||||
(total, entry) => total + (entry._data?.uncompressedSize ?? 0),
|
||||
0,
|
||||
);
|
||||
check(
|
||||
'ZIP 内文件体积之和与后台列表一致',
|
||||
zipBytes === item.totalBytes,
|
||||
`zip=${zipBytes} list=${item.totalBytes}`,
|
||||
);
|
||||
|
||||
let compared = 0;
|
||||
let missingLocally = 0;
|
||||
const mismatches = [];
|
||||
for (const entry of entries) {
|
||||
const localPath = path.join(PROJECT_DIR, ...entry.name.split('/'));
|
||||
let localBytes;
|
||||
try {
|
||||
const info = await stat(localPath);
|
||||
if (!info.isFile()) throw new Error('not a file');
|
||||
localBytes = await readFile(localPath);
|
||||
} catch {
|
||||
missingLocally += 1;
|
||||
continue;
|
||||
}
|
||||
const zipBytesForEntry = await entry.async('nodebuffer');
|
||||
compared += 1;
|
||||
if (sha256(zipBytesForEntry) !== sha256(localBytes)) {
|
||||
mismatches.push(entry.name);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'ZIP 条目在本地副本里都能找到',
|
||||
missingLocally === 0,
|
||||
`missing=${missingLocally}`,
|
||||
);
|
||||
check(
|
||||
'ZIP 内容与本地副本逐字节一致(SHA-256)',
|
||||
compared === entries.length && mismatches.length === 0,
|
||||
`compared=${compared} mismatches=${mismatches.length}${mismatches.length > 0 ? ` first=${mismatches[0]}` : ''}`,
|
||||
);
|
||||
|
||||
const excluded = entries
|
||||
.map((entry) => entry.name)
|
||||
.filter(
|
||||
(name) =>
|
||||
/(^|\/)\.git\//u.test(name) ||
|
||||
/(^|\/)node_modules\//u.test(name) ||
|
||||
/(^|\/)target\//u.test(name) ||
|
||||
/(^|\/)\.env(\.|$)/u.test(name),
|
||||
);
|
||||
check(
|
||||
'ZIP 里没有版本库 / node_modules / .env* 这类排除项',
|
||||
excluded.length === 0,
|
||||
excluded.length > 0 ? `first=${excluded[0]}` : '',
|
||||
);
|
||||
|
||||
const agentEntries = entries.filter((entry) =>
|
||||
entry.name.startsWith('.agent/'),
|
||||
).length;
|
||||
check(
|
||||
'ZIP 里确实带上了 .agent 状态目录',
|
||||
agentEntries > 0,
|
||||
`.agent files=${agentEntries}`,
|
||||
);
|
||||
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[check:agc-project-snapshot-admin-http] 运行失败:${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
// External v1 场景生成路由的本地真实栈受理 smoke。
|
||||
//
|
||||
// 用法:
|
||||
// E2E_API_BASE 默认 http://127.0.0.1:4198
|
||||
// E2E_EXTERNAL_API_KEY 可选;不给时脚本在 dev 环境注册一个账号并通过
|
||||
// /api/profile/api-keys 现建一个 Key(dev 密码登录会自动注册)。
|
||||
//
|
||||
// 覆盖:非法 Key 401 → 缺幂等键 400 → 空 sceneContent 400 → custom 缺 customStyle 400 →
|
||||
// 合法请求 202 且返回 operationId → 同键重放返回同一 operationId → 同键换请求体 409 →
|
||||
// 按 operationId 查一次生成状态(Provider 出图按环境可用性如实记录,不作为受理判据)。
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
|
||||
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
|
||||
const DEV_PASSWORD = 'GenE2e123!';
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, externalKey } = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
if (externalKey) finalHeaders.Authorization = `Bearer ${externalKey}`;
|
||||
let finalBody;
|
||||
if (body !== undefined) {
|
||||
finalHeaders['Content-Type'] = 'application/json';
|
||||
finalBody = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(`${API}${pathname}`, {
|
||||
method,
|
||||
headers: finalHeaders,
|
||||
body: finalBody,
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveExternalKey() {
|
||||
const provided = (process.env.E2E_EXTERNAL_API_KEY ?? '').trim();
|
||||
if (provided) return provided;
|
||||
|
||||
const phone = `133${String(Date.now()).slice(-8)}`;
|
||||
const registered = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
|
||||
});
|
||||
const token = registered.data?.token;
|
||||
check(
|
||||
'dev 账号注册/登录成功',
|
||||
registered.status === 200 && Boolean(token),
|
||||
`status=${registered.status}`,
|
||||
);
|
||||
if (!token) process.exit(1);
|
||||
|
||||
const created = await api('/api/profile/api-keys', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { name: 'E2E 场景路由 smoke' },
|
||||
});
|
||||
const apiKey = created.data?.apiKey;
|
||||
check(
|
||||
'创建外部 API Key 成功',
|
||||
created.status === 200 && Boolean(apiKey),
|
||||
`status=${created.status}`,
|
||||
);
|
||||
if (!apiKey) process.exit(1);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
const SCENE_PATH = '/api/external/v1/editor/scenes/generations';
|
||||
|
||||
async function main() {
|
||||
const apiKey = await resolveExternalKey();
|
||||
|
||||
const unauthorized = await api(SCENE_PATH, {
|
||||
method: 'POST',
|
||||
externalKey: 'tnr_sk_invalid_probe',
|
||||
headers: { 'Idempotency-Key': `scene-smoke-unauthorized-${Date.now()}` },
|
||||
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
|
||||
});
|
||||
check(
|
||||
'非法 API Key 被拒(401)',
|
||||
unauthorized.status === 401,
|
||||
`status=${unauthorized.status}`,
|
||||
);
|
||||
|
||||
const stamp = Date.now();
|
||||
const missingKey = await api(SCENE_PATH, {
|
||||
method: 'POST',
|
||||
externalKey: apiKey,
|
||||
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
|
||||
});
|
||||
check(
|
||||
'缺 Idempotency-Key 被拒(400)',
|
||||
missingKey.status === 400,
|
||||
`status=${missingKey.status}`,
|
||||
);
|
||||
|
||||
const blankScene = await api(SCENE_PATH, {
|
||||
method: 'POST',
|
||||
externalKey: apiKey,
|
||||
headers: { 'Idempotency-Key': `scene-smoke-blank-${stamp}` },
|
||||
body: { sceneContent: ' ', stylePreset: 'flat' },
|
||||
});
|
||||
check(
|
||||
'空 sceneContent 被拒(400)',
|
||||
blankScene.status === 400,
|
||||
`status=${blankScene.status}`,
|
||||
);
|
||||
|
||||
const customWithoutStyle = await api(SCENE_PATH, {
|
||||
method: 'POST',
|
||||
externalKey: apiKey,
|
||||
headers: { 'Idempotency-Key': `scene-smoke-custom-${stamp}` },
|
||||
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'custom' },
|
||||
});
|
||||
check(
|
||||
'custom 风格缺 customStyle 被拒(400)',
|
||||
customWithoutStyle.status === 400,
|
||||
`status=${customWithoutStyle.status}`,
|
||||
);
|
||||
|
||||
const idempotencyKey = `scene-smoke-${stamp}`;
|
||||
const body = {
|
||||
sceneContent: '夕阳下的海边小镇,远处有灯塔',
|
||||
stylePreset: 'flat',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
generationInputs: { source: 'ai-game-creator-client' },
|
||||
};
|
||||
const accepted = await api(SCENE_PATH, {
|
||||
method: 'POST',
|
||||
externalKey: apiKey,
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body,
|
||||
});
|
||||
const operationId = accepted.data?.operationId ?? accepted.data?.operation_id;
|
||||
check(
|
||||
'合法场景请求被受理(202 + operationId)',
|
||||
accepted.status === 202 && Boolean(operationId),
|
||||
`status=${accepted.status} operationId=${operationId ?? ''} msg=${accepted.error?.message ?? ''}`,
|
||||
);
|
||||
if (!operationId) process.exit(1);
|
||||
|
||||
const replayed = await api(SCENE_PATH, {
|
||||
method: 'POST',
|
||||
externalKey: apiKey,
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body,
|
||||
});
|
||||
check(
|
||||
'同键同请求重放返回同一 operationId',
|
||||
replayed.status === 202 &&
|
||||
(replayed.data?.operationId ?? replayed.data?.operation_id) ===
|
||||
operationId,
|
||||
`status=${replayed.status} operationId=${replayed.data?.operationId ?? ''}`,
|
||||
);
|
||||
|
||||
const conflicting = await api(SCENE_PATH, {
|
||||
method: 'POST',
|
||||
externalKey: apiKey,
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body: { ...body, sceneContent: '同一个幂等键换了画面内容' },
|
||||
});
|
||||
check(
|
||||
'同键换请求体被拒(409)',
|
||||
conflicting.status === 409,
|
||||
`status=${conflicting.status} code=${conflicting.error?.code ?? ''}`,
|
||||
);
|
||||
|
||||
let generation = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
generation = await api(`/api/external/v1/generations/${operationId}`, {
|
||||
externalKey: apiKey,
|
||||
});
|
||||
const status = generation.data?.status ?? '';
|
||||
if (
|
||||
status === 'completed' ||
|
||||
status === 'succeeded' ||
|
||||
status === 'failed' ||
|
||||
status === 'cancelled'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
}
|
||||
const status = generation?.data?.status ?? 'unknown';
|
||||
check(
|
||||
'按 operationId 能查到生成状态',
|
||||
generation?.status === 200 && status !== 'unknown',
|
||||
`status=${generation?.status} generationStatus=${status}`,
|
||||
);
|
||||
const result = generation?.data?.result ?? {};
|
||||
const assetObjectId = result.assetObjectId ?? result.asset?.assetObjectId;
|
||||
const objectKey = result.asset?.objectKey ?? result.objectKey ?? '';
|
||||
const providerProducedImage =
|
||||
status === 'completed' && Boolean(assetObjectId);
|
||||
check(
|
||||
'Provider 出图成功并返回素材引用(completed + assetObjectId)',
|
||||
providerProducedImage,
|
||||
`generationStatus=${status} assetObjectId=${assetObjectId ?? ''}`,
|
||||
);
|
||||
if (providerProducedImage && objectKey) {
|
||||
const readUrl = await api(
|
||||
`/api/external/v1/assets/read-url?objectKey=${encodeURIComponent(objectKey)}`,
|
||||
{ externalKey: apiKey },
|
||||
);
|
||||
const signedUrl = readUrl.data?.read?.signedUrl ?? readUrl.data?.signedUrl;
|
||||
const imageResponse = signedUrl ? await fetch(signedUrl) : null;
|
||||
const contentType = imageResponse?.headers.get('content-type') ?? '';
|
||||
check(
|
||||
'出图素材能通过 external read-url 换签名地址下载',
|
||||
readUrl.status === 200 &&
|
||||
Boolean(signedUrl) &&
|
||||
imageResponse?.status === 200 &&
|
||||
contentType.startsWith('image/'),
|
||||
`readUrl=${readUrl.status} objectKey=${objectKey} download=${imageResponse?.status ?? 'n/a'} type=${contentType}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`\nProvider 出图:${
|
||||
providerProducedImage
|
||||
? `已验证(assetObjectId=${assetObjectId}${objectKey ? ` objectKey=${objectKey}` : ''})`
|
||||
: `未验证(当前 generationStatus=${status},本机图像 Provider 不可用时属环境限制)`
|
||||
}`,
|
||||
);
|
||||
console.log(`生成状态原始响应:${generation?.text?.slice(0, 400) ?? ''}`);
|
||||
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`[check-external-v1-scene-generation-smoke] 运行失败:${error}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env node
|
||||
// 检查 game-distribution 的 Rust DTO 与手写 TS DTO 是否一致。
|
||||
//
|
||||
// 为什么需要它:`packages/shared/src/contracts/gameDistribution.ts` 是手写的,没有生成绑定兜底。
|
||||
// Rust 侧加字段而 TS 侧忘改时,只有跑起来才会发现。这里用一张显式映射表逐字段/逐变体比对:
|
||||
// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单;
|
||||
// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单;
|
||||
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
|
||||
// - 服务端手拼响应的构建器(`json!` / `object.insert`)单独比对顶层键:
|
||||
// 类型字段一致只说明契约写得对,这一层才有证据说明构建器真的按契约发键。
|
||||
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs';
|
||||
const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts';
|
||||
const API_MODULE_FILE =
|
||||
'server-rs/crates/api-server/src/modules/game_distribution.rs';
|
||||
|
||||
// [Rust 类型名, TS 类型名]
|
||||
const PAIRS = [
|
||||
[
|
||||
'GameDistributionPublishMetadataSuggestionRequest',
|
||||
'GameDistributionPublishMetadataSuggestionRequest',
|
||||
],
|
||||
[
|
||||
'GameDistributionPublishMetadataSuggestion',
|
||||
'GameDistributionPublishMetadataSuggestion',
|
||||
],
|
||||
['GameDistributionVersionStatus', 'GameDistributionVersionStatus'],
|
||||
['GameDistributionVisibility', 'GameDistributionGameVisibility'],
|
||||
['GameDistributionInputMode', 'GameDistributionInputMode'],
|
||||
['GameDistributionOrientation', 'GameDistributionOrientation'],
|
||||
['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'],
|
||||
['GameDistributionAuthor', 'GameDistributionAuthor'],
|
||||
['GameDistributionVersionSummary', 'GameDistributionVersionSummary'],
|
||||
['GameDistributionGameSummary', 'GameDistributionGame'],
|
||||
['GameDistributionListResponse', 'GameDistributionListResponse'],
|
||||
['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'],
|
||||
[
|
||||
'GameDistributionCreateVersionRequest',
|
||||
'GameDistributionCreateVersionRequest',
|
||||
],
|
||||
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
|
||||
];
|
||||
|
||||
// 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型
|
||||
// 没有 Rust 结构体可对:要收口得先把响应改成结构化类型,不能靠本脚本检查。
|
||||
const TS_ONLY_TYPES = [
|
||||
'GameDistributionCategory',
|
||||
'GameDistributionRecoveryAction',
|
||||
'GameDistributionFrozenScreenshot',
|
||||
'GameDistributionVersionFrozenMetadata',
|
||||
'GameDistributionVersionDetail',
|
||||
'GameDistributionCancelVersionRequest',
|
||||
'GameDistributionCancelVersionResponse',
|
||||
];
|
||||
|
||||
// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。
|
||||
// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影 = 作者投影 + currentVersion);
|
||||
// - `mustEmit`:TS 类型为了同时描述两种形状把该键标成可选,但这条路径必须发出。
|
||||
// 只解析顶层键;需要一并比对的嵌套对象用 `nested` 显式登记,没登记的嵌套对象不在证据范围内。
|
||||
const RESPONSE_BUILDERS = [
|
||||
{
|
||||
fn: 'game_payload',
|
||||
ts: 'GameDistributionGame',
|
||||
nested: [
|
||||
{ key: 'author', ts: 'GameDistributionAuthor' },
|
||||
{ key: 'deviceSupport', ts: 'GameDistributionDeviceSupport' },
|
||||
],
|
||||
},
|
||||
{
|
||||
fn: 'public_game_payload',
|
||||
ts: 'GameDistributionGame',
|
||||
base: 'game_payload',
|
||||
mustEmit: ['currentVersion'],
|
||||
},
|
||||
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
|
||||
{ fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' },
|
||||
];
|
||||
|
||||
function camelCase(value) {
|
||||
return value.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function snakeCase(value) {
|
||||
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
||||
}
|
||||
|
||||
// Rust 结构体字段本身就是 snake_case,枚举变体是 PascalCase;
|
||||
// serde 的 rename_all 决定线上名字。
|
||||
function renamedMember(value, kind, rename) {
|
||||
if (kind === 'enum') {
|
||||
if (rename === 'snake_case') return snakeCase(value);
|
||||
if (rename === 'camelCase') return camelCase(snakeCase(value));
|
||||
return value;
|
||||
}
|
||||
return rename === 'camelCase' ? camelCase(value) : value;
|
||||
}
|
||||
|
||||
function rustDefinitions(source) {
|
||||
const result = new Map();
|
||||
const pattern =
|
||||
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+)(?<body>[\s\S]*?)\n\}/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(source))) {
|
||||
const { attrs, kind, name, body } = match.groups;
|
||||
const rename = /rename_all = "(?<style>\w+)"/.exec(attrs)?.groups?.style;
|
||||
const members =
|
||||
kind === 'struct'
|
||||
? [...body.matchAll(/^\s*pub (\w+):/gm)].map((item) =>
|
||||
renamedMember(item[1], kind, rename),
|
||||
)
|
||||
: [...body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)].map((item) =>
|
||||
renamedMember(item[1], kind, rename),
|
||||
);
|
||||
result.set(name, { kind, members });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function tsDefinitions(source) {
|
||||
const result = new Map();
|
||||
const objectPattern =
|
||||
/export type (GameDistribution\w+) = \{([\s\S]*?)\n\};/g;
|
||||
let match;
|
||||
while ((match = objectPattern.exec(source))) {
|
||||
const members = [];
|
||||
const required = [];
|
||||
for (const line of match[2].split('\n')) {
|
||||
const member = /^ {2}(\w+)(\??):/.exec(line);
|
||||
if (!member) continue;
|
||||
members.push(member[1]);
|
||||
if (member[2] !== '?') required.push(member[1]);
|
||||
}
|
||||
result.set(match[1], { kind: 'struct', members, required });
|
||||
}
|
||||
const unionPattern = /export type (GameDistribution\w+) =\s*([^;]+);/g;
|
||||
while ((match = unionPattern.exec(source))) {
|
||||
if (result.has(match[1])) continue;
|
||||
result.set(match[1], {
|
||||
kind: 'enum',
|
||||
members: [...match[2].matchAll(/'([^']+)'/g)].map((item) => item[1]),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function difference(left, right) {
|
||||
const rightSet = new Set(right);
|
||||
return left.filter((value) => !rightSet.has(value));
|
||||
}
|
||||
|
||||
// 跳过 Rust 字符串字面量,返回结束引号的下标。
|
||||
function skipString(source, start) {
|
||||
let index = start + 1;
|
||||
while (index < source.length) {
|
||||
if (source[index] === '\\') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[index] === '"') return index;
|
||||
index += 1;
|
||||
}
|
||||
return source.length - 1;
|
||||
}
|
||||
|
||||
// 取顶层函数的函数体文本;找不到函数或括号不闭合时返回 null。
|
||||
function functionBody(source, name) {
|
||||
const signature = new RegExp(`\\nfn ${name}\\(`).exec(source);
|
||||
if (!signature) return null;
|
||||
const open = source.indexOf('{', signature.index + signature[0].length);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let index = open; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === '"') {
|
||||
index = skipString(source, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "'") {
|
||||
const charLiteral = /^'(?:\\.|[^'\\])'/.exec(source.slice(index));
|
||||
if (charLiteral) index += charLiteral[0].length - 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '{') depth += 1;
|
||||
else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(open + 1, index);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 取第一个 `json!({ ... })` 字面量文本;没有则返回 null。
|
||||
function firstJsonLiteral(body) {
|
||||
const marker = 'json!({';
|
||||
const start = body.indexOf(marker);
|
||||
if (start < 0) return null;
|
||||
const open = start + marker.length - 1;
|
||||
let depth = 0;
|
||||
for (let index = open; index < body.length; index += 1) {
|
||||
const char = body[index];
|
||||
if (char === '"') {
|
||||
index = skipString(body, index);
|
||||
continue;
|
||||
}
|
||||
if (char === '{' || char === '[' || char === '(') depth += 1;
|
||||
else if (char === '}' || char === ']' || char === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return body.slice(open, index + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 取括号对的结束下标;不闭合返回 -1。
|
||||
function matchingBracket(text, open) {
|
||||
let depth = 0;
|
||||
for (let index = open; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char === '"') {
|
||||
index = skipString(text, index);
|
||||
continue;
|
||||
}
|
||||
if (char === '{' || char === '[' || char === '(') depth += 1;
|
||||
else if (char === '}' || char === ']' || char === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 取对象字面量的直接键:只有上一个有效字符是 `{` 或 `,` 且下一字符是 `:`
|
||||
// 的字符串才算键,因此嵌套对象的键与作为值的字符串不会混进来。
|
||||
function objectKeys(text) {
|
||||
const keys = [];
|
||||
let inner = 0;
|
||||
let previous = '';
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char === '"') {
|
||||
const close = skipString(text, index);
|
||||
if (
|
||||
inner === 1 &&
|
||||
(previous === '{' || previous === ',') &&
|
||||
text[close + 1] === ':'
|
||||
) {
|
||||
keys.push(text.slice(index + 1, close));
|
||||
previous = ':';
|
||||
index = close;
|
||||
continue;
|
||||
}
|
||||
previous = '"';
|
||||
index = close;
|
||||
continue;
|
||||
}
|
||||
if (char === '{' || char === '[' || char === '(') inner += 1;
|
||||
else if (char === '}' || char === ']' || char === ')') inner -= 1;
|
||||
if (!/\s/.test(char)) previous = char;
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// 取对象字面量里某个直接键对应的嵌套对象字面量文本;该键不是对象字面量时返回 null。
|
||||
function nestedObjectText(text, wanted) {
|
||||
let inner = 0;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char === '"') {
|
||||
const close = skipString(text, index);
|
||||
if (inner === 1 && text.slice(index + 1, close) === wanted) {
|
||||
let cursor = close + 1;
|
||||
while (cursor < text.length && /\s/.test(text[cursor])) cursor += 1;
|
||||
if (text[cursor] === ':') {
|
||||
let open = cursor + 1;
|
||||
while (open < text.length && /\s/.test(text[open])) open += 1;
|
||||
if (text[open] !== '{') return null;
|
||||
const end = matchingBracket(text, open);
|
||||
return end < 0 ? null : text.slice(open, end + 1);
|
||||
}
|
||||
}
|
||||
index = close;
|
||||
continue;
|
||||
}
|
||||
if (char === '{' || char === '[' || char === '(') inner += 1;
|
||||
else if (char === '}' || char === ']' || char === ')') inner -= 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 取函数体里顶层追加的键:`object.insert("key".to_string(), ...)`。
|
||||
function insertedKeys(body) {
|
||||
return [...body.matchAll(/object\.insert\(\s*"([^"]+)"/g)].map(
|
||||
(item) => item[1],
|
||||
);
|
||||
}
|
||||
|
||||
const rust = rustDefinitions(fs.readFileSync(RUST_FILE, 'utf8'));
|
||||
const ts = tsDefinitions(fs.readFileSync(TS_FILE, 'utf8'));
|
||||
const failures = [];
|
||||
const mappedRust = new Set(PAIRS.map(([rustName]) => rustName));
|
||||
const mappedTs = new Set(PAIRS.map(([, tsName]) => tsName));
|
||||
|
||||
for (const [rustName, tsName] of PAIRS) {
|
||||
const rustDefinition = rust.get(rustName);
|
||||
const tsDefinition = ts.get(tsName);
|
||||
if (!rustDefinition) {
|
||||
failures.push(`映射表里的 Rust 类型不存在:${rustName}`);
|
||||
continue;
|
||||
}
|
||||
if (!tsDefinition) {
|
||||
failures.push(`映射表里的 TS 类型不存在:${tsName}`);
|
||||
continue;
|
||||
}
|
||||
if (rustDefinition.kind !== tsDefinition.kind) {
|
||||
failures.push(
|
||||
`${rustName} 与 ${tsName} 形状不一致(Rust=${rustDefinition.kind} TS=${tsDefinition.kind})`,
|
||||
);
|
||||
}
|
||||
const missingInTs = difference(rustDefinition.members, tsDefinition.members);
|
||||
if (missingInTs.length > 0) {
|
||||
failures.push(
|
||||
`${tsName} 缺少字段/变体:${missingInTs.join(', ')}(${rustName} 已有)`,
|
||||
);
|
||||
}
|
||||
const extraInTs = difference(tsDefinition.members, rustDefinition.members);
|
||||
if (extraInTs.length > 0) {
|
||||
failures.push(
|
||||
`${tsName} 多出未登记字段/变体:${extraInTs.join(', ')}(要么在 Rust 侧补上,要么先确认真实响应不再发出)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of rust.keys()) {
|
||||
if (!mappedRust.has(name)) {
|
||||
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);
|
||||
}
|
||||
}
|
||||
|
||||
const apiSource = fs.readFileSync(API_MODULE_FILE, 'utf8');
|
||||
const emittedByBuilder = new Map();
|
||||
for (const builder of RESPONSE_BUILDERS) {
|
||||
const tsDefinition = ts.get(builder.ts);
|
||||
if (!tsDefinition) {
|
||||
failures.push(`响应构建器 ${builder.fn} 的 TS 类型不存在:${builder.ts}`);
|
||||
continue;
|
||||
}
|
||||
const body = functionBody(apiSource, builder.fn);
|
||||
if (body === null) {
|
||||
failures.push(`响应构建器 ${builder.fn} 在 ${API_MODULE_FILE} 里找不到`);
|
||||
continue;
|
||||
}
|
||||
const literal = firstJsonLiteral(body);
|
||||
const literalKeys = literal ? objectKeys(literal) : [];
|
||||
const ownKeys = [...new Set([...literalKeys, ...insertedKeys(body)])];
|
||||
let emitted = ownKeys;
|
||||
if (builder.base) {
|
||||
if (literalKeys.length > 0) {
|
||||
failures.push(
|
||||
`${builder.fn} 声明了 base=${builder.base},却自己也有 json! 字面量;两者只能取其一`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (ownKeys.length === 0) {
|
||||
failures.push(
|
||||
`${builder.fn} 声明了 base=${builder.base},但没有解析到任何顶层 object.insert("…")`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const baseKeys = emittedByBuilder.get(builder.base);
|
||||
if (!baseKeys) {
|
||||
failures.push(
|
||||
`${builder.fn} 的 base=${builder.base} 必须登记在它前面,才能复用已解析的键`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
emitted = [...new Set([...baseKeys, ...ownKeys])];
|
||||
} else if (literalKeys.length === 0) {
|
||||
failures.push(`${builder.fn} 没有解析到任何 json!({ … }) 顶层键`);
|
||||
continue;
|
||||
}
|
||||
emittedByBuilder.set(builder.fn, emitted);
|
||||
|
||||
const unknown = difference(emitted, tsDefinition.members);
|
||||
if (unknown.length > 0) {
|
||||
failures.push(
|
||||
`${builder.fn} 发出 ${builder.ts} 没有的键:${unknown.join(', ')}(TS 类型必须同步)`,
|
||||
);
|
||||
}
|
||||
const missing = difference(tsDefinition.required ?? [], emitted);
|
||||
if (missing.length > 0) {
|
||||
failures.push(
|
||||
`${builder.fn} 没有发出 ${builder.ts} 的必需字段:${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const mustEmit = difference(builder.mustEmit ?? [], emitted);
|
||||
if (mustEmit.length > 0) {
|
||||
failures.push(`${builder.fn} 必须发出的键缺失:${mustEmit.join(', ')}`);
|
||||
}
|
||||
for (const nested of builder.nested ?? []) {
|
||||
const nestedDefinition = ts.get(nested.ts);
|
||||
if (!nestedDefinition) {
|
||||
failures.push(
|
||||
`${builder.fn} 的嵌套对象 ${nested.key} 对应的 TS 类型不存在:${nested.ts}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const nestedText = literal ? nestedObjectText(literal, nested.key) : null;
|
||||
if (!nestedText) {
|
||||
failures.push(
|
||||
`${builder.fn} 的 ${nested.key} 不是对象字面量,无法与 ${nested.ts} 比对`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const nestedKeys = objectKeys(nestedText);
|
||||
const nestedUnknown = difference(nestedKeys, nestedDefinition.members);
|
||||
if (nestedUnknown.length > 0) {
|
||||
failures.push(
|
||||
`${builder.fn} 的 ${nested.key} 发出 ${nested.ts} 没有的键:${nestedUnknown.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const nestedMissing = difference(
|
||||
nestedDefinition.required ?? [],
|
||||
nestedKeys,
|
||||
);
|
||||
if (nestedMissing.length > 0) {
|
||||
failures.push(
|
||||
`${builder.fn} 的 ${nested.key} 没有发出 ${nested.ts} 的必需字段:${nestedMissing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const builder of RESPONSE_BUILDERS) {
|
||||
if (!emittedByBuilder.has(builder.fn)) {
|
||||
failures.push(
|
||||
`响应构建器 ${builder.fn} 的键没有解析成功,无法证明与 TS 一致`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of ts.keys()) {
|
||||
if (!mappedTs.has(name) && !TS_ONLY_TYPES.includes(name)) {
|
||||
failures.push(
|
||||
`TS 新增类型 ${name} 未分类(映射表或 TS_ONLY_TYPES 二者必居其一)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:game-distribution-dto-parity] 不一致:');
|
||||
for (const failure of failures) console.error(` - ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`[check:game-distribution-dto-parity] OK:${PAIRS.length} 组 Rust/TS 类型一致,` +
|
||||
`${RESPONSE_BUILDERS.length} 个手拼响应构建器键一致,` +
|
||||
`${TS_ONLY_TYPES.length} 个手拼响应类型已登记`,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
// 游戏分发「作者归属与越权隔离」真实链路检查(需要本地 dev 栈:SpacetimeDB standalone + api-server)。
|
||||
//
|
||||
// 用法:
|
||||
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
|
||||
// npm run check:game-distribution-owner-isolation
|
||||
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。
|
||||
//
|
||||
// 覆盖:两个真实账号注册 → 作者建游戏与版本 → 另一个账号读私有版本、读上传状态、写分片、
|
||||
// 确认分包、送审、撤回全部按「不可见」处理 → 未认证读私有版本 401 → 越权写没有副作用
|
||||
// (作者侧已收字节仍为 0)→ 请求体里伪造 owner 不生效(游戏仍归请求者、不出现在被冒名账号
|
||||
// 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入
|
||||
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(dev bucket,走现役直传 + 确认链路),
|
||||
// 发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
|
||||
const COVER_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
|
||||
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!';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function api(path, 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}${path}`, {
|
||||
method,
|
||||
headers: finalHeaders,
|
||||
body: finalBody,
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
json,
|
||||
text,
|
||||
data: json?.data,
|
||||
error: json?.error,
|
||||
meta: json?.meta,
|
||||
ok: json?.ok,
|
||||
};
|
||||
}
|
||||
|
||||
function gameMetadata(title) {
|
||||
return {
|
||||
title,
|
||||
summary: '越权隔离用例的临时游戏',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['e2e'],
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `owner-isolation-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'owner-isolation', 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-owner-isolation',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(
|
||||
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
|
||||
async function register(prefix) {
|
||||
const response = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
purePhoneNumber: `${prefix}${String(Date.now()).slice(-8)}`,
|
||||
password: DEV_PASSWORD,
|
||||
},
|
||||
});
|
||||
return { response, token: response.data?.token };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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 author = await register('137');
|
||||
const intruder = await register('138');
|
||||
check(
|
||||
'作者注册拿到 token',
|
||||
author.response.status === 200 && Boolean(author.token),
|
||||
`status=${author.response.status}`,
|
||||
);
|
||||
check(
|
||||
'另一个账号注册拿到 token',
|
||||
intruder.response.status === 200 && Boolean(intruder.token),
|
||||
`status=${intruder.response.status}`,
|
||||
);
|
||||
if (!author.token || !intruder.token) process.exit(1);
|
||||
|
||||
const setGate = (enabled, rolloutPercent) =>
|
||||
api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: admin,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled,
|
||||
rolloutPercent,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 作者归属隔离',
|
||||
},
|
||||
});
|
||||
|
||||
const gateOpen = await setGate(true, 100);
|
||||
check(
|
||||
'发布灰度可开启并放量',
|
||||
gateOpen.status === 200,
|
||||
`status=${gateOpen.status}`,
|
||||
);
|
||||
|
||||
const stamp = Date.now();
|
||||
const title = `越权隔离 ${String(stamp).slice(-6)}`;
|
||||
const coverAssetId = await uploadCover(author.token, stamp);
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author.token,
|
||||
headers: { 'Idempotency-Key': `iso-game-${stamp}` },
|
||||
body: { ...gameMetadata(title), coverAssetId },
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check(
|
||||
'作者创建游戏成功',
|
||||
created.status === 200 && Boolean(gameId),
|
||||
`status=${created.status} msg=${created.error?.message ?? ''}`,
|
||||
);
|
||||
if (!gameId) process.exit(1);
|
||||
|
||||
const versionResponse = await api(
|
||||
`/api/game-distribution/games/${gameId}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author.token,
|
||||
headers: { 'Idempotency-Key': `iso-version-${stamp}` },
|
||||
body: {
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageBytes: 2048,
|
||||
packageFileCount: 2,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: { ...gameMetadata(title), coverAssetId },
|
||||
},
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
const publicationRevision = versionResponse.data?.publicationRevision ?? 0;
|
||||
check(
|
||||
'作者创建版本成功(awaiting_upload)',
|
||||
versionResponse.status === 200 &&
|
||||
Boolean(versionId) &&
|
||||
versionResponse.data?.status === 'awaiting_upload',
|
||||
`status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`,
|
||||
);
|
||||
if (!versionId) process.exit(1);
|
||||
|
||||
const ownRead = await api(`/api/game-distribution/versions/${versionId}`, {
|
||||
token: author.token,
|
||||
});
|
||||
check(
|
||||
'作者读到自己的私有版本',
|
||||
ownRead.status === 200 && ownRead.data?.version?.versionId === versionId,
|
||||
`status=${ownRead.status}`,
|
||||
);
|
||||
|
||||
const anonymousRead = await api(
|
||||
`/api/game-distribution/versions/${versionId}`,
|
||||
);
|
||||
check(
|
||||
'未认证读私有版本 401',
|
||||
anonymousRead.status === 401,
|
||||
`status=${anonymousRead.status}`,
|
||||
);
|
||||
|
||||
const foreignRead = await api(
|
||||
`/api/game-distribution/versions/${versionId}`,
|
||||
{ token: intruder.token },
|
||||
);
|
||||
check(
|
||||
'他人读私有版本按不存在处理(404)',
|
||||
foreignRead.status === 404,
|
||||
`status=${foreignRead.status} code=${foreignRead.error?.code ?? ''}`,
|
||||
);
|
||||
|
||||
// 成功 / 失败 envelope 的形状:TS 侧 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts`
|
||||
// 的运行时守卫读的就是这几个字段(data / error.code / meta.apiVersion / meta.requestId)。
|
||||
check(
|
||||
'成功响应 envelope 带 ok/data 与 meta.apiVersion / meta.requestId',
|
||||
created.status === 200 &&
|
||||
created.data !== undefined &&
|
||||
created.ok === true &&
|
||||
created.error === null &&
|
||||
typeof created.meta?.apiVersion === 'string' &&
|
||||
created.meta.apiVersion.length > 0 &&
|
||||
typeof created.meta?.requestId === 'string' &&
|
||||
created.meta.requestId.length > 0,
|
||||
`apiVersion=${created.meta?.apiVersion ?? ''} requestId=${created.meta?.requestId ?? ''}`,
|
||||
);
|
||||
check(
|
||||
'失败响应 envelope 带 ok=false/error.code 与 meta.requestId',
|
||||
foreignRead.status === 404 &&
|
||||
foreignRead.ok === false &&
|
||||
typeof foreignRead.error?.code === 'string' &&
|
||||
foreignRead.error.code.length > 0 &&
|
||||
foreignRead.data === null &&
|
||||
typeof foreignRead.meta?.requestId === 'string' &&
|
||||
foreignRead.meta.requestId.length > 0,
|
||||
`code=${foreignRead.error?.code ?? ''} requestId=${foreignRead.meta?.requestId ?? ''}`,
|
||||
);
|
||||
|
||||
const foreignState = await api(
|
||||
`/api/game-distribution/versions/${versionId}/package/upload-state`,
|
||||
{ token: intruder.token },
|
||||
);
|
||||
check(
|
||||
'他人读上传状态 404',
|
||||
foreignState.status === 404,
|
||||
`status=${foreignState.status}`,
|
||||
);
|
||||
|
||||
const foreignChunk = await api(
|
||||
`/api/game-distribution/versions/${versionId}/package/chunk`,
|
||||
{
|
||||
method: 'PUT',
|
||||
token: intruder.token,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Idempotency-Key': `iso-chunk-${stamp}`,
|
||||
'x-genarrative-upload-offset': '0',
|
||||
},
|
||||
binary: Buffer.alloc(16, 7),
|
||||
},
|
||||
);
|
||||
check(
|
||||
'他人写发行包分片 404',
|
||||
foreignChunk.status === 404,
|
||||
`status=${foreignChunk.status}`,
|
||||
);
|
||||
|
||||
const foreignComplete = await api(
|
||||
`/api/game-distribution/versions/${versionId}/package/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: intruder.token,
|
||||
headers: { 'Idempotency-Key': `iso-complete-${stamp}` },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'他人确认分包 404',
|
||||
foreignComplete.status === 404,
|
||||
`status=${foreignComplete.status}`,
|
||||
);
|
||||
|
||||
const foreignSubmit = await api(
|
||||
`/api/game-distribution/versions/${versionId}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: intruder.token,
|
||||
headers: { 'Idempotency-Key': `iso-submit-${stamp}` },
|
||||
body: { expectedPublicationRevision: publicationRevision },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'他人送审 404',
|
||||
foreignSubmit.status === 404,
|
||||
`status=${foreignSubmit.status} code=${foreignSubmit.error?.code ?? ''} msg=${foreignSubmit.error?.message ?? ''}`,
|
||||
);
|
||||
|
||||
const nonexistentSubmit = await api(
|
||||
`/api/game-distribution/versions/gamever_missing_${stamp}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: intruder.token,
|
||||
headers: { 'Idempotency-Key': `iso-missing-submit-${stamp}` },
|
||||
body: { expectedPublicationRevision: 0 },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'他人送审不存在的版本 404(对照)',
|
||||
nonexistentSubmit.status === 404,
|
||||
`status=${nonexistentSubmit.status} code=${nonexistentSubmit.error?.code ?? ''}`,
|
||||
);
|
||||
|
||||
const foreignCancel = await api(
|
||||
`/api/game-distribution/versions/${versionId}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: intruder.token,
|
||||
headers: { 'Idempotency-Key': `iso-cancel-${stamp}` },
|
||||
body: { expectedPublicationRevision: publicationRevision },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'他人撤回 404',
|
||||
foreignCancel.status === 404,
|
||||
`status=${foreignCancel.status}`,
|
||||
);
|
||||
|
||||
const authorState = await api(
|
||||
`/api/game-distribution/versions/${versionId}/package/upload-state`,
|
||||
{ token: author.token },
|
||||
);
|
||||
check(
|
||||
'越权写没有落副作用(作者侧已收字节仍为 0)',
|
||||
authorState.status === 200 && authorState.data?.receivedBytes === 0,
|
||||
`status=${authorState.status} receivedBytes=${authorState.data?.receivedBytes}`,
|
||||
);
|
||||
|
||||
const intruderCoverAssetId = await uploadCover(
|
||||
intruder.token,
|
||||
`${stamp}-intruder`,
|
||||
);
|
||||
const forged = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: intruder.token,
|
||||
headers: { 'Idempotency-Key': `iso-forged-${stamp}` },
|
||||
body: {
|
||||
...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`),
|
||||
coverAssetId: intruderCoverAssetId,
|
||||
ownerUserId: 'user_forged',
|
||||
owner_user_id: 'user_forged',
|
||||
},
|
||||
});
|
||||
const forgedGameId = forged.data?.id;
|
||||
check(
|
||||
'请求体伪造 owner 不影响创建成功',
|
||||
forged.status === 200 && Boolean(forgedGameId),
|
||||
`status=${forged.status} msg=${forged.error?.message ?? ''}`,
|
||||
);
|
||||
|
||||
const intruderGames = await api('/api/game-distribution/my-games', {
|
||||
token: intruder.token,
|
||||
});
|
||||
const forgedStaysWithRequester =
|
||||
Array.isArray(intruderGames.data?.games) &&
|
||||
intruderGames.data.games.some((game) => game.id === forgedGameId);
|
||||
check(
|
||||
'伪造 owner 的游戏仍归请求者(在请求者 my-games 里)',
|
||||
forgedStaysWithRequester,
|
||||
`games=${intruderGames.data?.games?.length ?? 'n/a'}`,
|
||||
);
|
||||
|
||||
const authorGames = await api('/api/game-distribution/my-games', {
|
||||
token: author.token,
|
||||
});
|
||||
const forgedLeakedToAuthor =
|
||||
Array.isArray(authorGames.data?.games) &&
|
||||
authorGames.data.games.some((game) => game.id === forgedGameId);
|
||||
check(
|
||||
'伪造 owner 的游戏不出现在被冒名账号的 my-games',
|
||||
!forgedLeakedToAuthor,
|
||||
`authorGames=${authorGames.data?.games?.length ?? 'n/a'}`,
|
||||
);
|
||||
|
||||
const ownCancel = await api(
|
||||
`/api/game-distribution/versions/${versionId}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author.token,
|
||||
headers: { 'Idempotency-Key': `iso-own-cancel-${stamp}` },
|
||||
body: { expectedPublicationRevision: publicationRevision },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'作者撤回自己的版本成功(cancelled)',
|
||||
ownCancel.status === 200 && ownCancel.data?.version?.status === 'cancelled',
|
||||
`status=${ownCancel.status} versionStatus=${ownCancel.data?.version?.status}`,
|
||||
);
|
||||
|
||||
const gateClosed = await setGate(false, 0);
|
||||
check(
|
||||
'发布灰度恢复关闭',
|
||||
gateClosed.status === 200,
|
||||
`status=${gateClosed.status}`,
|
||||
);
|
||||
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[check:game-distribution-owner-isolation] 运行失败:${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
// 游戏分发「分片上传中断 / api-server 进程退出后按权威偏移续传」编排检查。
|
||||
//
|
||||
// 需要本地 dev 栈(SpacetimeDB standalone + api-server),两个阶段之间由调用方重启 api-server:
|
||||
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=prepare \
|
||||
// npm run check:game-distribution-upload-resume
|
||||
// <重启 api-server>
|
||||
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=resume \
|
||||
// npm run check:game-distribution-upload-resume
|
||||
//
|
||||
// E2E_STATE_FILE 可覆盖状态文件路径(默认在系统临时目录),E2E_API_BASE 默认 http://127.0.0.1:4198。
|
||||
//
|
||||
// prepare:注册作者 → 建游戏与版本(声明整包摘要/字节数)→ 造一个大于 8 MiB 的真实 ZIP →
|
||||
// 按分片协议只上传第一片(8 MiB)→ 断言权威已收字节就是第一片长度。
|
||||
// resume :重新登录同一作者 → 断言灰度配置在进程重启后仍然生效 → 读权威已收字节(来自 OSS,
|
||||
// 不是进程内状态)→ 从该偏移续传剩余字节 → 确认整包 → 送审。
|
||||
// 这条链路只在本机 dev 数据库与该 dev bucket 的对象键下落行。
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const COVER_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
|
||||
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 STAGE = (process.env.E2E_STAGE ?? 'prepare').trim();
|
||||
const DEV_PASSWORD = 'GenE2e123!';
|
||||
const CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
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);
|
||||
}
|
||||
if (STAGE !== 'prepare' && STAGE !== 'resume') {
|
||||
console.error(`E2E_STAGE 只支持 prepare / resume,收到:${STAGE}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
json,
|
||||
text,
|
||||
data: json?.data,
|
||||
error: json?.error,
|
||||
};
|
||||
}
|
||||
|
||||
async function stateFilePaths() {
|
||||
const configured = (process.env.E2E_STATE_FILE ?? '').trim();
|
||||
if (configured) {
|
||||
return { state: configured, zip: `${configured}.zip` };
|
||||
}
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'genarrative-gd-resume-'));
|
||||
const state = path.join(dir, 'state.json');
|
||||
return { state, zip: `${state}.zip` };
|
||||
}
|
||||
|
||||
async function adminToken() {
|
||||
const login = await api('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
|
||||
});
|
||||
const token = login.data?.token ?? login.data?.accessToken;
|
||||
check(
|
||||
'管理员登录成功',
|
||||
login.status === 200 && Boolean(token),
|
||||
`status=${login.status}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
const setGate = (token, enabled, rolloutPercent) =>
|
||||
api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled,
|
||||
rolloutPercent,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 分片续传',
|
||||
},
|
||||
});
|
||||
|
||||
function gameMetadata(title) {
|
||||
return {
|
||||
title,
|
||||
summary: '分片续传用例的临时游戏',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['e2e'],
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `upload-resume-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'upload-resume', 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-upload-resume',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(
|
||||
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
|
||||
// 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。
|
||||
async function buildLargeFixtureZip() {
|
||||
const archive = new JSZip();
|
||||
archive.file('index.html', '<!doctype html><title>resume</title>');
|
||||
archive.file('assets/app.js', "console.log('resume');");
|
||||
archive.file('assets/blob.bin', randomBytes(9 * 1024 * 1024));
|
||||
const bytes = await archive.generateAsync({ type: 'uint8array' });
|
||||
return { bytes: Buffer.from(bytes), fileCount: 3 };
|
||||
}
|
||||
|
||||
async function uploadChunk(token, versionId, bytes, offset, key) {
|
||||
return api(`/api/game-distribution/versions/${versionId}/package/chunk`, {
|
||||
method: 'PUT',
|
||||
token,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Idempotency-Key': key,
|
||||
'x-genarrative-upload-offset': String(offset),
|
||||
},
|
||||
binary: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
async function runPrepare(paths) {
|
||||
const admin = await adminToken();
|
||||
if (!admin) process.exit(1);
|
||||
const gateOpen = await setGate(admin, true, 100);
|
||||
check(
|
||||
'发布灰度可开启并放量',
|
||||
gateOpen.status === 200,
|
||||
`status=${gateOpen.status}`,
|
||||
);
|
||||
|
||||
const stamp = Date.now();
|
||||
const phone = `136${String(stamp).slice(-8)}`;
|
||||
const register = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
|
||||
});
|
||||
const author = register.data?.token;
|
||||
check(
|
||||
'作者注册拿到 token',
|
||||
register.status === 200 && Boolean(author),
|
||||
`status=${register.status}`,
|
||||
);
|
||||
if (!author) process.exit(1);
|
||||
|
||||
const built = await buildLargeFixtureZip();
|
||||
const zipBytes = built.bytes;
|
||||
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex');
|
||||
check(
|
||||
'fixture 发行包大于单个分片',
|
||||
zipBytes.length > CHUNK_BYTES,
|
||||
`packageBytes=${zipBytes.length}`,
|
||||
);
|
||||
await writeFile(paths.zip, zipBytes);
|
||||
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const title = `分片续传 ${String(stamp).slice(-6)}`;
|
||||
const metadata = { ...gameMetadata(title), coverAssetId };
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `resume-game-${stamp}` },
|
||||
body: metadata,
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check(
|
||||
'创建游戏成功',
|
||||
created.status === 200 && Boolean(gameId),
|
||||
`status=${created.status} msg=${created.error?.message ?? ''}`,
|
||||
);
|
||||
if (!gameId) process.exit(1);
|
||||
|
||||
const versionResponse = await api(
|
||||
`/api/game-distribution/games/${gameId}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `resume-version-${stamp}` },
|
||||
body: {
|
||||
packageSha256: zipSha256,
|
||||
packageBytes: zipBytes.length,
|
||||
packageFileCount: built.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
check(
|
||||
'创建版本成功(awaiting_upload)',
|
||||
versionResponse.status === 200 &&
|
||||
Boolean(versionId) &&
|
||||
versionResponse.data?.status === 'awaiting_upload',
|
||||
`status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`,
|
||||
);
|
||||
if (!versionId) process.exit(1);
|
||||
|
||||
const firstChunk = zipBytes.subarray(0, CHUNK_BYTES);
|
||||
const uploaded = await uploadChunk(
|
||||
author,
|
||||
versionId,
|
||||
firstChunk,
|
||||
0,
|
||||
`resume-chunk-1-${stamp}`,
|
||||
);
|
||||
check(
|
||||
'第一片上传成功',
|
||||
uploaded.status === 200 && uploaded.data?.receivedBytes === CHUNK_BYTES,
|
||||
`status=${uploaded.status} receivedBytes=${uploaded.data?.receivedBytes ?? 'n/a'}`,
|
||||
);
|
||||
|
||||
const state = await api(
|
||||
`/api/game-distribution/versions/${versionId}/package/upload-state`,
|
||||
{ token: author },
|
||||
);
|
||||
check(
|
||||
'权威已收字节等于第一片长度',
|
||||
state.status === 200 && state.data?.receivedBytes === CHUNK_BYTES,
|
||||
`status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`,
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
paths.state,
|
||||
JSON.stringify(
|
||||
{
|
||||
apiBase: API,
|
||||
stage: 'prepared',
|
||||
phone,
|
||||
password: DEV_PASSWORD,
|
||||
gameId,
|
||||
versionId,
|
||||
packageSha256: zipSha256,
|
||||
packageBytes: zipBytes.length,
|
||||
packageFileCount: built.fileCount,
|
||||
firstChunkBytes: CHUNK_BYTES,
|
||||
zipPath: paths.zip,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
`\nPREPARED gameId=${gameId} versionId=${versionId} packageBytes=${zipBytes.length} receivedBytes=${CHUNK_BYTES}`,
|
||||
);
|
||||
console.log(
|
||||
'下一步:重启 api-server,再用 E2E_STAGE=resume 运行本脚本(同一 E2E_STATE_FILE)。',
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
async function runResume(paths) {
|
||||
const raw = await readFile(paths.state, 'utf8');
|
||||
const saved = JSON.parse(raw);
|
||||
const zipBytes = await readFile(saved.zipPath);
|
||||
check(
|
||||
'状态文件与 fixture 齐备',
|
||||
saved.stage === 'prepared' &&
|
||||
saved.versionId &&
|
||||
zipBytes.length === saved.packageBytes,
|
||||
`versionId=${saved.versionId ?? ''} packageBytes=${zipBytes.length}/${saved.packageBytes}`,
|
||||
);
|
||||
|
||||
const admin = await adminToken();
|
||||
if (!admin) process.exit(1);
|
||||
|
||||
const login = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: { purePhoneNumber: saved.phone, password: saved.password },
|
||||
});
|
||||
const author = login.data?.token;
|
||||
check(
|
||||
'重启后同一作者仍能登录',
|
||||
login.status === 200 && Boolean(author),
|
||||
`status=${login.status}`,
|
||||
);
|
||||
if (!author) process.exit(1);
|
||||
|
||||
const frontendConfig = await api('/api/runtime/frontend-config', {
|
||||
token: author,
|
||||
});
|
||||
check(
|
||||
'灰度配置在 api-server 重启后仍然生效',
|
||||
frontendConfig.status === 200 &&
|
||||
frontendConfig.data?.gameDistributionPublishEnabled === true,
|
||||
`status=${frontendConfig.status} enabled=${frontendConfig.data?.gameDistributionPublishEnabled}`,
|
||||
);
|
||||
|
||||
const state = await api(
|
||||
`/api/game-distribution/versions/${saved.versionId}/package/upload-state`,
|
||||
{ token: author },
|
||||
);
|
||||
check(
|
||||
'重启后权威已收字节仍等于第一片长度(来自对象存储)',
|
||||
state.status === 200 && state.data?.receivedBytes === saved.firstChunkBytes,
|
||||
`status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`,
|
||||
);
|
||||
|
||||
const remaining = zipBytes.subarray(saved.firstChunkBytes);
|
||||
const resumed = await uploadChunk(
|
||||
author,
|
||||
saved.versionId,
|
||||
remaining,
|
||||
saved.firstChunkBytes,
|
||||
`resume-chunk-2-${saved.versionId}`,
|
||||
);
|
||||
check(
|
||||
'按权威偏移续传剩余字节成功',
|
||||
resumed.status === 200 &&
|
||||
resumed.data?.receivedBytes === saved.packageBytes,
|
||||
`status=${resumed.status} receivedBytes=${resumed.data?.receivedBytes ?? 'n/a'} expected=${saved.packageBytes}`,
|
||||
);
|
||||
|
||||
const completed = await api(
|
||||
`/api/game-distribution/versions/${saved.versionId}/package/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `resume-complete-${saved.versionId}` },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'整包确认成功(回到可送审状态)',
|
||||
completed.status === 200 && completed.data?.status === 'uploaded',
|
||||
`status=${completed.status} versionStatus=${completed.data?.status ?? ''} msg=${completed.error?.message ?? ''}`,
|
||||
);
|
||||
|
||||
const submitted = await api(
|
||||
`/api/game-distribution/versions/${saved.versionId}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `resume-submit-${saved.versionId}` },
|
||||
body: {
|
||||
expectedPublicationRevision: completed.data?.publicationRevision ?? 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
check(
|
||||
'送审成功(202 + pending_review)',
|
||||
submitted.status === 202 &&
|
||||
submitted.data?.version?.status === 'pending_review',
|
||||
`status=${submitted.status} versionStatus=${submitted.data?.version?.status ?? ''}`,
|
||||
);
|
||||
|
||||
const gateClosed = await setGate(admin, false, 0);
|
||||
check(
|
||||
'发布灰度恢复关闭',
|
||||
gateClosed.status === 200,
|
||||
`status=${gateClosed.status}`,
|
||||
);
|
||||
|
||||
await rm(saved.zipPath, { force: true });
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
const paths = await stateFilePaths();
|
||||
if (STAGE === 'prepare') {
|
||||
await runPrepare(paths);
|
||||
} else {
|
||||
await runResume(paths);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
// 游戏分发网页侧「导航 / 键盘可达 / 全屏 / 安全区 / 移动发布面板」检查。
|
||||
//
|
||||
// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。
|
||||
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
|
||||
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
|
||||
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-a11y-e2e
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const COVER_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
|
||||
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!';
|
||||
|
||||
if (!ADMIN_USER || !ADMIN_PASSWORD) {
|
||||
console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
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 uploadCover(token, id) {
|
||||
const fileName = `a11y-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'a11y', 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}`);
|
||||
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-a11y',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
|
||||
async function loginThroughWebUi(page, phone) {
|
||||
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
|
||||
// 移动端每次进入都可能重新弹「创作用电脑端」提示,它会盖住账号入口。
|
||||
await dismissMobileNotice(page);
|
||||
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
|
||||
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();
|
||||
await page
|
||||
.locator('.platform-account-entry')
|
||||
.first()
|
||||
.filter({ hasText: phone.slice(-4) })
|
||||
.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端首次进入(以及每次重新进入)会弹「移动端仅支持展示体验、创作用电脑端」的阻断提示,
|
||||
* 它会盖住底部导航,交互前先关掉。
|
||||
*/
|
||||
async function dismissMobileNotice(page) {
|
||||
const notice = page.locator('.platform-overlay');
|
||||
// 提示可能在首帧之后才挂上,先等一小会儿再判断,避免"看起来没有"却被它盖住。
|
||||
await notice
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
.catch(() => {});
|
||||
if ((await notice.count()) === 0) return;
|
||||
await notice
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click({ timeout: 5_000 })
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function publishFixtureGame(adminToken, stamp, title) {
|
||||
await api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 100,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 可达性',
|
||||
},
|
||||
});
|
||||
const register = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
purePhoneNumber: `130${String(stamp).slice(-8)}`,
|
||||
password: DEV_PASSWORD,
|
||||
},
|
||||
});
|
||||
const author = register.data?.token;
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'index.html',
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>a11y</title>' +
|
||||
'<script src="assets/app.js"></script></head><body><h1>A11Y-OK</h1></body></html>',
|
||||
);
|
||||
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="a11y";');
|
||||
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
|
||||
const sha256 = (await import('node:crypto'))
|
||||
.createHash('sha256')
|
||||
.update(bytes)
|
||||
.digest('hex');
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const metadata = {
|
||||
title,
|
||||
summary: '可达性 E2E 临时游戏',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['e2e'],
|
||||
coverAssetId,
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
};
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `a11y-game-${stamp}` },
|
||||
body: metadata,
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `a11y-version-${stamp}` },
|
||||
body: {
|
||||
packageSha256: sha256,
|
||||
packageBytes: bytes.length,
|
||||
packageFileCount: 2,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
});
|
||||
const versionId = version.data?.versionId;
|
||||
const uploaded = await api(
|
||||
`/api/game-distribution/versions/${versionId}/package`,
|
||||
{
|
||||
method: 'PUT',
|
||||
token: author,
|
||||
headers: {
|
||||
'Idempotency-Key': `a11y-upload-${stamp}`,
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
binary: bytes,
|
||||
},
|
||||
);
|
||||
const submitted = await api(
|
||||
`/api/game-distribution/versions/${versionId}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `a11y-submit-${stamp}` },
|
||||
body: { expectedPublicationRevision: 0 },
|
||||
},
|
||||
);
|
||||
const approved = await api(
|
||||
`/admin/api/game-distribution/versions/${versionId}/review`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
headers: { 'Idempotency-Key': `a11y-approve-${stamp}` },
|
||||
body: { decision: 'approve', expectedPublicationRevision: 0 },
|
||||
},
|
||||
);
|
||||
if (
|
||||
created.status !== 200 ||
|
||||
uploaded.status !== 200 ||
|
||||
submitted.status !== 202 ||
|
||||
approved.status !== 200
|
||||
) {
|
||||
throw new Error(
|
||||
`发布夹具失败 created=${created.status} uploaded=${uploaded.status} submitted=${submitted.status} approved=${approved.status}`,
|
||||
);
|
||||
}
|
||||
return { gameId, author, metadata };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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 stamp = Date.now();
|
||||
const title = `可达性 ${String(stamp).slice(-6)}`;
|
||||
const { gameId } = await publishFixtureGame(admin, stamp, title);
|
||||
check('测试游戏已发布', Boolean(gameId), `gameId=${gameId ?? ''}`);
|
||||
|
||||
const { chromium } = await loadPlaywright();
|
||||
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// 桌面左侧导航:点「游戏」「我的」都能切换页面。
|
||||
await page.goto(`${WEB}/games`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page
|
||||
.locator('button.platform-desktop-rail__button', { hasText: '游戏' })
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page
|
||||
.locator('button.platform-desktop-rail__button', { hasText: '我的' })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(/\/profile/, { timeout: 30_000 });
|
||||
check('桌面导航切到我的', true, `url=${page.url()}`);
|
||||
await page
|
||||
.locator('button.platform-desktop-rail__button', { hasText: '游戏' })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(/\/games$/, { timeout: 30_000 });
|
||||
check('桌面导航切回游戏广场', true, `url=${page.url()}`);
|
||||
|
||||
// 键盘可达:只用 Tab + Enter 从目录进入详情、进游玩页并开始游戏。
|
||||
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
|
||||
const card = page.locator('button.game-card:visible', { hasText: title });
|
||||
await card.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await card.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL(/\/games\/detail\?id=/, { timeout: 30_000 });
|
||||
check('键盘在目录里能用 Enter 打开焦点卡片', true, `url=${page.url()}`);
|
||||
|
||||
const playButton = page.getByRole('button', { name: '立即玩' });
|
||||
await playButton.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await playButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL(/\/games\/play\?id=/, { timeout: 30_000 });
|
||||
const startButton = page.getByRole('button', { name: '开始游戏' });
|
||||
await startButton.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await startButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.locator('iframe.game-player-frame')
|
||||
.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
check('键盘可以从详情一路进到开始游戏', true);
|
||||
|
||||
// 全屏:开始前禁用,开始后可用(headless 下不强制断言真的进入全屏)。
|
||||
const fullscreenButton = page.getByRole('button', { name: '全屏' });
|
||||
check(
|
||||
'开始游戏后全屏按钮可用',
|
||||
!(await fullscreenButton.isDisabled()),
|
||||
'hasStarted=true',
|
||||
);
|
||||
await fullscreenButton.click().catch(() => {});
|
||||
const enteredFullscreen = await page.evaluate(
|
||||
() => document.fullscreenElement !== null,
|
||||
);
|
||||
console.log(
|
||||
`INFO 全屏尝试结果:fullscreenElement=${enteredFullscreen}(headless 环境可能不授予)`,
|
||||
);
|
||||
|
||||
// 安全区:移动壳层与底部导航都用 env(safe-area-inset-*),静态核对样式来源。
|
||||
const indexCss = await readFile('src/index.css', 'utf8');
|
||||
check(
|
||||
'移动壳层与底部导航使用安全区内边距',
|
||||
indexCss.includes('--platform-bottom-nav-height') &&
|
||||
indexCss.includes('env(safe-area-inset-bottom'),
|
||||
'src/index.css',
|
||||
);
|
||||
|
||||
// 移动端导航 + 发布面板:底部 dock 能切页,发布表单在窄屏可用且不横向溢出。
|
||||
const mobile = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
const mobilePage = await mobile.newPage();
|
||||
await mobilePage.goto(`${WEB}/games`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await dismissMobileNotice(mobilePage);
|
||||
const mobileNav = mobilePage.getByRole('navigation', {
|
||||
name: '移动平台导航',
|
||||
});
|
||||
await mobileNav.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await mobileNav.getByRole('button', { name: '我的' }).click();
|
||||
await mobilePage.waitForURL(/\/profile/, { timeout: 30_000 });
|
||||
check('移动端底部导航切到我的', true, `url=${mobilePage.url()}`);
|
||||
await dismissMobileNotice(mobilePage);
|
||||
await mobileNav.getByRole('button', { name: '游戏' }).click();
|
||||
await mobilePage.waitForURL(/\/games$/, { timeout: 30_000 });
|
||||
check('移动端底部导航切回游戏', true, `url=${mobilePage.url()}`);
|
||||
|
||||
const mobilePhone = `136${String(stamp).slice(-8)}`;
|
||||
await dismissMobileNotice(mobilePage);
|
||||
await loginThroughWebUi(mobilePage, mobilePhone);
|
||||
await mobilePage.goto(`${WEB}/games/publish`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await dismissMobileNotice(mobilePage);
|
||||
await mobilePage
|
||||
.getByLabel('游戏名称')
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
const overflow = await mobilePage.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
check(
|
||||
'移动端发布面板可用且没有整页横向溢出',
|
||||
overflow <= 1,
|
||||
`overflow=${overflow}px`,
|
||||
);
|
||||
await mobile.close();
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: admin,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: false,
|
||||
rolloutPercent: 0,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 可达性(收尾关闭)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[check-game-distribution-web-a11y-e2e] 运行失败:${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
// 游戏分发网页发布链路检查:真实 dev 网页栈 + Chromium,作者在网页里登录、选封面、选发行包
|
||||
// ZIP、填资料并提交,随后核对「同一账号在网页里看到待审状态」「审核通过后出现在广场」。
|
||||
//
|
||||
// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。
|
||||
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
|
||||
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
|
||||
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-publish-e2e
|
||||
// E2E_WEB_BASE 默认 http://127.0.0.1:3000,E2E_API_BASE 默认 http://127.0.0.1:8082。
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, writeFile } 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 WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
|
||||
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!';
|
||||
|
||||
if (!ADMIN_USER || !ADMIN_PASSWORD) {
|
||||
console.error(
|
||||
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要通过网页真实发布并审核。',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {} } = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
if (body !== undefined) {
|
||||
finalHeaders['Content-Type'] = 'application/json';
|
||||
finalBody = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(`${API}${pathname}`, {
|
||||
method,
|
||||
headers: finalHeaders,
|
||||
body: finalBody,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
|
||||
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
|
||||
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();
|
||||
await page
|
||||
.locator('.platform-account-entry')
|
||||
.first()
|
||||
.filter({ hasText: phone.slice(-4) })
|
||||
.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 100,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 网页发布链路',
|
||||
},
|
||||
});
|
||||
check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
|
||||
|
||||
const stamp = Date.now();
|
||||
const phone = `131${String(stamp).slice(-8)}`;
|
||||
const title = `网页发布 ${String(stamp).slice(-6)}`;
|
||||
const workDir = await mkdtemp(
|
||||
path.join(tmpdir(), 'genarrative-web-publish-'),
|
||||
);
|
||||
const coverPath = path.join(workDir, 'cover.png');
|
||||
const zipPath = path.join(workDir, 'package.zip');
|
||||
await writeFile(coverPath, COVER_PNG);
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'index.html',
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>web publish</title>' +
|
||||
'<script src="assets/app.js"></script></head><body><h1>WEB-PUBLISH-OK</h1></body></html>',
|
||||
);
|
||||
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="publish";');
|
||||
const zipBytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
|
||||
await writeFile(zipPath, zipBytes);
|
||||
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex');
|
||||
|
||||
const { chromium } = await loadPlaywright();
|
||||
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
let gameId = '';
|
||||
let versionId = '';
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
await loginThroughWebUi(page, phone);
|
||||
check('网页里用密码登录/注册成功', true, `phone=${phone}`);
|
||||
|
||||
await page.goto(`${WEB}/games/publish`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page
|
||||
.getByLabel('游戏名称')
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page.getByLabel('游戏名称').fill(title);
|
||||
await page.getByLabel('一句话简介').fill('网页发布链路 E2E');
|
||||
await page
|
||||
.getByLabel('详细介绍')
|
||||
.fill('由 Playwright 在真实网页里提交的发行包。');
|
||||
await page.getByLabel('分类').selectOption('休闲');
|
||||
await page.getByLabel('标签').fill('e2e');
|
||||
await page.getByLabel('游戏封面(必需)').setInputFiles(coverPath);
|
||||
await page
|
||||
.locator('img[alt="游戏封面预览"]')
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page.getByLabel('发行包 ZIP').setInputFiles(zipPath);
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: '提交审核' }).first().click();
|
||||
await page
|
||||
.getByText('已提交审核,审核通过后会自动出现在游戏广场。')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
check('网页提交发行包 ZIP 后进入待审', true);
|
||||
|
||||
const authorLogin = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
|
||||
});
|
||||
const author = authorLogin.data?.token;
|
||||
const mine = await api('/api/game-distribution/my-games', {
|
||||
token: author,
|
||||
});
|
||||
const mineGame = (mine.data?.games ?? []).find(
|
||||
(item) => item.title === title,
|
||||
);
|
||||
gameId = mineGame?.id ?? '';
|
||||
versionId = mineGame?.versions?.[0]?.versionId ?? '';
|
||||
check(
|
||||
'同一账号在服务端看到该游戏为审核中',
|
||||
mineGame?.latestVersion?.status === 'pending_review' &&
|
||||
mineGame?.status === 'unpublished',
|
||||
`gameId=${gameId} versionStatus=${mineGame?.latestVersion?.status ?? ''} visibility=${mineGame?.status ?? ''}`,
|
||||
);
|
||||
const catalogBefore = await api('/api/game-distribution/games?limit=100');
|
||||
check(
|
||||
'待审期间广场看不到该游戏',
|
||||
!(catalogBefore.data?.games ?? []).some((item) => item.id === gameId),
|
||||
);
|
||||
|
||||
await page.goto(`${WEB}/games/mine`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page
|
||||
.getByText(title)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
check(
|
||||
'网页「我的游戏」显示审核中',
|
||||
await page.getByText('审核中').first().isVisible(),
|
||||
);
|
||||
|
||||
const approved = await api(
|
||||
`/admin/api/game-distribution/versions/${versionId}/review`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: admin,
|
||||
headers: { 'Idempotency-Key': `web-publish-approve-${stamp}` },
|
||||
body: { decision: 'approve', expectedPublicationRevision: 0 },
|
||||
},
|
||||
);
|
||||
check(
|
||||
'管理员审核通过网页提交的版本',
|
||||
approved.status === 200,
|
||||
`status=${approved.status}`,
|
||||
);
|
||||
const catalogAfter = await api('/api/game-distribution/games?limit=100');
|
||||
const publishedGame = (catalogAfter.data?.games ?? []).find(
|
||||
(item) => item.id === gameId,
|
||||
);
|
||||
check(
|
||||
'审核通过后广场出现该游戏且入口由服务端派生',
|
||||
Boolean(publishedGame) &&
|
||||
publishedGame.currentVersion?.entryUrl === `/games/${gameId}/`,
|
||||
`entryUrl=${publishedGame?.currentVersion?.entryUrl ?? ''}`,
|
||||
);
|
||||
await page.goto(`${WEB}/games/mine`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page
|
||||
.getByText(title)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
check(
|
||||
'网页「我的游戏」同步显示为已发布',
|
||||
await page.getByText('已发布').first().isVisible(),
|
||||
);
|
||||
|
||||
const mineAfter = await api('/api/game-distribution/my-games', {
|
||||
token: author,
|
||||
});
|
||||
const sameGame = (mineAfter.data?.games ?? []).find(
|
||||
(item) => item.id === gameId,
|
||||
);
|
||||
check(
|
||||
'同一 gameId 的账号状态在两端读同一份后端数据',
|
||||
Boolean(sameGame) &&
|
||||
sameGame.status === 'published' &&
|
||||
sameGame.latestVersion?.packageSha256 === zipSha256,
|
||||
`status=${sameGame?.status ?? ''} sha=${String(sameGame?.latestVersion?.packageSha256).slice(0, 12)} expected=${zipSha256.slice(0, 12)}`,
|
||||
);
|
||||
|
||||
// 更新沿用同一 gameId:网页发布页带 ?game= 回填旧版,提交新版本后仍是同一个游戏。
|
||||
const updateZip = new JSZip();
|
||||
updateZip.file(
|
||||
'index.html',
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>web publish v2</title>' +
|
||||
'<script src="assets/app.js"></script></head><body><h1>WEB-PUBLISH-V2</h1></body></html>',
|
||||
);
|
||||
updateZip.file(
|
||||
'assets/app.js',
|
||||
'document.documentElement.dataset.e2e="publish-v2";',
|
||||
);
|
||||
const updateBytes = Buffer.from(
|
||||
await updateZip.generateAsync({ type: 'uint8array' }),
|
||||
);
|
||||
const updateZipPath = path.join(workDir, 'package-v2.zip');
|
||||
await writeFile(updateZipPath, updateBytes);
|
||||
await page.goto(`${WEB}/games/publish?game=${gameId}`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page
|
||||
.getByText('提交新版本审核')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page.getByLabel('发行包 ZIP').setInputFiles(updateZipPath);
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: '提交新版本审核' }).first().click();
|
||||
await page
|
||||
.getByText('已提交审核,审核通过后会自动出现在游戏广场。')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
const mineAfterUpdate = await api('/api/game-distribution/my-games', {
|
||||
token: author,
|
||||
});
|
||||
const updatedGame = (mineAfterUpdate.data?.games ?? []).find(
|
||||
(item) => item.id === gameId,
|
||||
);
|
||||
check(
|
||||
'网页提交新版本沿用同一 gameId 且旧版仍在线',
|
||||
Boolean(updatedGame) &&
|
||||
updatedGame.versions?.length === 2 &&
|
||||
updatedGame.latestVersion?.status === 'pending_review' &&
|
||||
updatedGame.status === 'published',
|
||||
`versions=${updatedGame?.versions?.length ?? 'n/a'} latest=${updatedGame?.latestVersion?.status ?? ''} status=${updatedGame?.status ?? ''}`,
|
||||
);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: admin,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: false,
|
||||
rolloutPercent: 0,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 网页发布链路(收尾关闭)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[check-game-distribution-web-publish-e2e] 运行失败:${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
// 游戏分发网页发布「操作恢复」检查:双击提交只落一份、发行包上传中断后草稿仍在、
|
||||
// 关窗重开后能继续同一版本、换账号不会看到前一个账号的私有草稿。
|
||||
//
|
||||
// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。
|
||||
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
|
||||
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
|
||||
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-publish-recovery-e2e
|
||||
import { mkdtemp, writeFile } 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 WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
|
||||
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 DRAFT_KEY = 'genarrative.game-distribution.publish-draft.v1';
|
||||
|
||||
if (!ADMIN_USER || !ADMIN_PASSWORD) {
|
||||
console.error(
|
||||
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要打开发布灰度并核对发布草稿。',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
if (!ok) failures += 1;
|
||||
console.log(
|
||||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body } = options;
|
||||
const headers = { ...ENVELOPE };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
finalBody = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(`${API}${pathname}`, {
|
||||
method,
|
||||
headers,
|
||||
body: finalBody,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
|
||||
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
|
||||
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();
|
||||
await page
|
||||
.locator('.platform-account-entry')
|
||||
.first()
|
||||
.filter({ hasText: phone.slice(-4) })
|
||||
.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function fillPublishForm(page, title, coverPath, zipPath) {
|
||||
await page.goto(`${WEB}/games/publish`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page
|
||||
.getByLabel('游戏名称')
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page.getByLabel('游戏名称').fill(title);
|
||||
await page.getByLabel('一句话简介').fill('发布恢复链路 E2E');
|
||||
await page.getByLabel('分类').selectOption('休闲');
|
||||
await page.getByLabel('游戏封面(必需)').setInputFiles(coverPath);
|
||||
await page
|
||||
.locator('img[alt="游戏封面预览"]')
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page.getByLabel('发行包 ZIP').setInputFiles(zipPath);
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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);
|
||||
await api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: admin,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 100,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 发布恢复链路',
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = Date.now();
|
||||
const phone = `139${String(stamp).slice(-8)}`;
|
||||
const title = `发布恢复 ${String(stamp).slice(-6)}`;
|
||||
const workDir = await mkdtemp(
|
||||
path.join(tmpdir(), 'genarrative-publish-recovery-'),
|
||||
);
|
||||
const coverPath = path.join(workDir, 'cover.png');
|
||||
const zipPath = path.join(workDir, 'package.zip');
|
||||
await writeFile(coverPath, COVER_PNG);
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'index.html',
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>recovery</title>' +
|
||||
'<script src="assets/app.js"></script></head><body><h1>RECOVERY-OK</h1></body></html>',
|
||||
);
|
||||
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="recovery";');
|
||||
await writeFile(
|
||||
zipPath,
|
||||
Buffer.from(await zip.generateAsync({ type: 'uint8array' })),
|
||||
);
|
||||
|
||||
const { chromium } = await loadPlaywright();
|
||||
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
let draftSnapshot = '';
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginThroughWebUi(page, phone);
|
||||
check('作者网页登录成功', true, `phone=${phone}`);
|
||||
|
||||
let abortUploads = true;
|
||||
let interrupted = false;
|
||||
await page.route(
|
||||
'**/api/game-distribution/versions/*/package*',
|
||||
async (route) => {
|
||||
if (abortUploads) {
|
||||
interrupted = true;
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
await fillPublishForm(page, title, coverPath, zipPath);
|
||||
const submitButton = page.getByRole('button', { name: '提交审核' }).first();
|
||||
await submitButton.click();
|
||||
// 双击:第二次点击发生在提交中,按钮应已禁用,不产生第二份游戏/版本。
|
||||
await submitButton.click({ force: true, timeout: 3_000 }).catch(() => {});
|
||||
await page.waitForTimeout(4_000);
|
||||
check('发行包上传失败时给出错误提示', interrupted, '已拦截一次上传请求');
|
||||
|
||||
const authorLogin = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
|
||||
});
|
||||
const author = authorLogin.data?.token;
|
||||
const mineAfterFailure = await api('/api/game-distribution/my-games', {
|
||||
token: author,
|
||||
});
|
||||
const gamesAfterFailure = (mineAfterFailure.data?.games ?? []).filter(
|
||||
(item) => item.title === title,
|
||||
);
|
||||
check(
|
||||
'双击提交只落一份游戏与一个版本',
|
||||
gamesAfterFailure.length === 1 &&
|
||||
gamesAfterFailure[0].versions?.length === 1,
|
||||
`games=${gamesAfterFailure.length} versions=${gamesAfterFailure[0]?.versions?.length ?? 'n/a'}`,
|
||||
);
|
||||
// 后面续传阶段放行上传。
|
||||
abortUploads = false;
|
||||
draftSnapshot = await page.evaluate(
|
||||
(key) => localStorage.getItem(key) ?? '',
|
||||
DRAFT_KEY,
|
||||
);
|
||||
const draft = draftSnapshot ? JSON.parse(draftSnapshot) : null;
|
||||
check(
|
||||
'上传中断后本地保留恢复草稿',
|
||||
Boolean(draft?.versionId) && draft.gameId === gamesAfterFailure[0]?.id,
|
||||
`versionId=${draft?.versionId ?? ''}`,
|
||||
);
|
||||
|
||||
// 关窗 + 登录失效:清掉 access token 与 refresh cookie 后重登同一账号,
|
||||
// 草稿仍在 localStorage 里,应该还能接着原版本上传。
|
||||
await page.close();
|
||||
const clearing = await context.newPage();
|
||||
await clearing.goto(`${WEB}/games`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await clearing.evaluate(() => {
|
||||
try {
|
||||
localStorage.removeItem('genarrative.auth.access-token.v1');
|
||||
} catch {
|
||||
// 存储不可用时下面按登出后的恢复提示判定。
|
||||
}
|
||||
});
|
||||
await context.clearCookies();
|
||||
await clearing.close();
|
||||
|
||||
const reopened = await context.newPage();
|
||||
await reopened.goto(`${WEB}/games/publish`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
const loginPrompt = await reopened
|
||||
.getByText('登录后才能发布游戏')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
check('登录失效后发布页要求重新登录', loginPrompt);
|
||||
await loginThroughWebUi(reopened, phone);
|
||||
await reopened.goto(`${WEB}/games/publish`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await reopened
|
||||
.getByText(/上次发布未完成(版本 v1)/u)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
check('重新登录后仍能看到继续上传的恢复提示', true);
|
||||
await reopened.getByRole('button', { name: '继续上传' }).first().click();
|
||||
await reopened.getByLabel('发行包 ZIP').setInputFiles(zipPath);
|
||||
await reopened.waitForTimeout(400);
|
||||
await reopened
|
||||
.getByRole('button', { name: '继续上传并送审' })
|
||||
.first()
|
||||
.click();
|
||||
await reopened
|
||||
.getByText('已提交审核,审核通过后会自动出现在游戏广场。')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
const mineAfterResume = await api('/api/game-distribution/my-games', {
|
||||
token: author,
|
||||
});
|
||||
const resumedGame = (mineAfterResume.data?.games ?? []).find(
|
||||
(item) => item.title === title,
|
||||
);
|
||||
check(
|
||||
'继续上传沿用原版本并进入待审',
|
||||
resumedGame?.versions?.length === 1 &&
|
||||
resumedGame?.versions?.[0]?.versionId === draft?.versionId &&
|
||||
resumedGame?.latestVersion?.status === 'pending_review',
|
||||
`versions=${resumedGame?.versions?.length ?? 'n/a'} status=${resumedGame?.latestVersion?.status ?? ''}`,
|
||||
);
|
||||
const draftAfterResume = await reopened.evaluate(
|
||||
(key) => localStorage.getItem(key) ?? '',
|
||||
DRAFT_KEY,
|
||||
);
|
||||
check('恢复成功后清理本地草稿', draftAfterResume === '');
|
||||
await reopened.close();
|
||||
await context.close();
|
||||
|
||||
// 换账号:把前一个账号的草稿留在同一浏览器里,新账号不应看到恢复提示。
|
||||
const otherPhone = `137${String(stamp + 7).slice(-8)}`;
|
||||
const otherContext = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
await otherContext.addInitScript(
|
||||
({ key, value }) => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// 存储不可用时这条用例不成立,下面按「没有恢复提示」判定。
|
||||
}
|
||||
},
|
||||
{ key: DRAFT_KEY, value: draftSnapshot },
|
||||
);
|
||||
const otherPage = await otherContext.newPage();
|
||||
await loginThroughWebUi(otherPage, otherPhone);
|
||||
await otherPage.goto(`${WEB}/games/publish`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await otherPage
|
||||
.getByLabel('游戏名称')
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await otherPage.waitForTimeout(1_500);
|
||||
const leakedDraft = await otherPage.getByText(/上次发布未完成/u).count();
|
||||
check(
|
||||
'换账号后不会显示上一个账号的私有草稿',
|
||||
leakedDraft === 0,
|
||||
`matches=${leakedDraft}`,
|
||||
);
|
||||
await otherContext.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: admin,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: false,
|
||||
rolloutPercent: 0,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 发布恢复链路(收尾关闭)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`[check-game-distribution-web-publish-recovery-e2e] 运行失败:${error}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 校验 ts-rs 生成的共享契约绑定与 Rust 声明一致。
|
||||
*
|
||||
* 做法是「重新生成一遍并与工作区现有内容逐字节比较」:只比 git diff 会漏掉「Rust 改了但
|
||||
* 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接——
|
||||
* 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
const generatedDir = path.join(
|
||||
repoRoot,
|
||||
'packages/shared/src/contracts/generated',
|
||||
);
|
||||
|
||||
function snapshot() {
|
||||
if (!fs.existsSync(generatedDir)) return new Map();
|
||||
return new Map(
|
||||
fs
|
||||
.readdirSync(generatedDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => [
|
||||
entry.name,
|
||||
fs.readFileSync(path.join(generatedDir, entry.name)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const before = snapshot();
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
[
|
||||
'test',
|
||||
'--locked',
|
||||
'-p',
|
||||
'shared-contracts',
|
||||
'--features',
|
||||
'ts-bindings',
|
||||
'--manifest-path',
|
||||
'server-rs/Cargo.toml',
|
||||
'export_bindings',
|
||||
],
|
||||
{ cwd: repoRoot, encoding: 'utf8' },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error('生成绑定校验无法运行:export_bindings 未通过');
|
||||
if (result.stdout) console.error(result.stdout.trim());
|
||||
if (result.stderr) console.error(result.stderr.trim());
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
const after = snapshot();
|
||||
|
||||
const problems = [];
|
||||
for (const [name, content] of after) {
|
||||
const previous = before.get(name);
|
||||
if (!previous) problems.push(`新增 ${name}`);
|
||||
else if (!previous.equals(content)) problems.push(`内容变化 ${name}`);
|
||||
}
|
||||
for (const name of before.keys()) {
|
||||
if (!after.has(name)) problems.push(`删除 ${name}`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error('生成绑定与 Rust 声明不一致:');
|
||||
for (const problem of problems) console.error(` - ${problem}`);
|
||||
console.error(
|
||||
'请运行 `cargo test -p shared-contracts --features ts-bindings export_bindings`,并把重新生成的结果与 Rust 改动一并提交。',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`生成绑定校验通过:${after.size} 个文件与 Rust 声明一致(${path.relative(repoRoot, generatedDir)})。`,
|
||||
);
|
||||
+43
-124
@@ -10,9 +10,6 @@ const nativeShellPlanPath =
|
||||
'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md';
|
||||
const hostBridgeProtocolDocPath =
|
||||
'docs/【前端架构】宿主壳能力统一协议-2026-06-17.md';
|
||||
const developmentWorkflowDocPath =
|
||||
'docs/project-memory/shared-memory/development-workflow.md';
|
||||
const decisionLogDocPath = 'docs/project-memory/shared-memory/decision-log.md';
|
||||
const rootPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const mobileShellConfigCheckSource = fs.readFileSync(
|
||||
'apps/mobile-shell/scripts/check-config.mjs',
|
||||
@@ -2564,21 +2561,47 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
!aiGameCreatorClientHttpSource.includes(
|
||||
"'https://dev.genarrative.world'",
|
||||
) ||
|
||||
!aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'") ||
|
||||
!aiGameCreatorClientHttpSource.includes('target.origin !==')
|
||||
!aiGameCreatorClientHttpSource.includes(
|
||||
"'https://www.genarrative.world'",
|
||||
) ||
|
||||
/\bfetch\s*\(/u.test(aiGameCreatorClientHttpSource) ||
|
||||
aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'")
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator release dev API transport boundary drifted',
|
||||
'AI game creator clientHttp must only resolve the server selection, not carry a transport',
|
||||
);
|
||||
}
|
||||
// 渲染层是离线前端:平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部在 Rust 侧
|
||||
// (`src-tauri/src/account_api.rs` / `auth_session.rs` / `platform_asset_upload.rs` /
|
||||
// `game_distribution_publish.rs` 等 reqwest facade)。这里对整棵渲染源码加网络原语与
|
||||
// Tauri HTTP guest 的负向门禁,避免以后再长出第二条传输路径。
|
||||
const rendererNetworkPrimitivePatterns = [
|
||||
['fetch(', /\bfetch\s*\(/u],
|
||||
['XMLHttpRequest', /\bXMLHttpRequest\b/u],
|
||||
['EventSource', /\bEventSource\b/u],
|
||||
['sendBeacon(', /\bsendBeacon\s*\(/u],
|
||||
['new WebSocket(', /\bnew\s+WebSocket\s*\(/u],
|
||||
['@tauri-apps/plugin-http', /@tauri-apps\/plugin-http/u],
|
||||
];
|
||||
for (const rendererFilePath of collectFiles(
|
||||
'apps/ai-game-creator-shell/src',
|
||||
(entryPath) => /\.(ts|tsx)$/u.test(entryPath),
|
||||
)) {
|
||||
const rendererSource = fs.readFileSync(rendererFilePath, 'utf8');
|
||||
for (const [label, pattern] of rendererNetworkPrimitivePatterns) {
|
||||
if (pattern.test(rendererSource)) {
|
||||
throw new Error(
|
||||
`AI game creator renderer must stay offline, but ${normalizeScannedFilePath(rendererFilePath)} contains ${label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!aiGameCreatorCargoManifestSource.includes(
|
||||
'tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }',
|
||||
) ||
|
||||
!aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
|
||||
aiGameCreatorCargoManifestSource.includes('tauri-plugin-http') ||
|
||||
aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator release must register the native HTTP plugin without the OS automatic system-proxy feature',
|
||||
'AI game creator shell must not register a renderer HTTP plugin; native IO goes through the Rust reqwest facades',
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -2588,27 +2611,18 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'AI game creator monorepo build must dedupe React runtime packages',
|
||||
);
|
||||
}
|
||||
const httpPermission = (aiGameCreatorMainCapability.permissions ?? []).find(
|
||||
(permission) =>
|
||||
typeof permission === 'object' &&
|
||||
permission?.identifier === 'http:default',
|
||||
);
|
||||
if (
|
||||
!httpPermission ||
|
||||
JSON.stringify(httpPermission.allow ?? []) !==
|
||||
JSON.stringify([
|
||||
{ url: 'https://dev.genarrative.world/api/*' },
|
||||
{ url: 'https://www.genarrative.world/api/*' },
|
||||
{ url: 'https://*/api/*' },
|
||||
{ url: 'http://localhost:*/*' },
|
||||
{ url: 'http://127.0.0.1:*/*' },
|
||||
{ url: 'https://*.aliyuncs.com/*' },
|
||||
])
|
||||
(aiGameCreatorMainCapability.permissions ?? []).some(
|
||||
(permission) =>
|
||||
permission === 'http:default' ||
|
||||
(typeof permission === 'object' &&
|
||||
permission?.identifier === 'http:default'),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
// 更新清单与安装包下载由 tauri-plugin-updater 在原生侧完成;
|
||||
// 封面与截图仍通过 webview 的 http 插件向凭证指定的 OSS 地址直传。
|
||||
'AI game creator native HTTP scope must match the release, dev, custom HTTPS, loopback API, and OSS media upload boundary',
|
||||
// 客户端窗口只保留剪贴板、图片、资源关闭、opener、updater 与原生对话框权限;
|
||||
// 更新清单下载由 tauri-plugin-updater 在原生侧完成,素材直传也不再经渲染层。
|
||||
'AI game creator client capability must not grant the renderer HTTP permission',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3757,107 +3771,12 @@ function extractDocumentMethodTable(source) {
|
||||
);
|
||||
}
|
||||
|
||||
function assertNativeShellScaffoldScanWording(source, label) {
|
||||
if (
|
||||
source.includes('三端生产壳临时替身词扫描') ||
|
||||
source.includes('三端壳生产源码') ||
|
||||
source.includes('壳生产源码禁替身') ||
|
||||
!source.includes('H5 HostBridge 真实调用链的临时替身词扫描')
|
||||
) {
|
||||
throw new Error(
|
||||
`${label} must document production scaffold scanning for the H5 HostBridge call chain`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeShellCapabilityPlan() {
|
||||
const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8');
|
||||
const hostBridgeProtocolDocSource = fs.readFileSync(
|
||||
hostBridgeProtocolDocPath,
|
||||
'utf8',
|
||||
);
|
||||
const developmentWorkflowDocSource = fs.readFileSync(
|
||||
developmentWorkflowDocPath,
|
||||
'utf8',
|
||||
);
|
||||
const decisionLogDocSource = fs.readFileSync(decisionLogDocPath, 'utf8');
|
||||
if (
|
||||
planSource.includes('permissions` 必须只包含 `core:default`') ||
|
||||
planSource.includes(
|
||||
'permissions=["core:default","allow-host-bridge-request"]',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'native shell plan must not document core:default as a desktop capability permission',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!planSource.includes('主窗口 capability 只授予 `allow-host-bridge-request`')
|
||||
) {
|
||||
throw new Error(
|
||||
'native shell plan must document the minimal desktop capability permission',
|
||||
);
|
||||
}
|
||||
for (const staleShareWording of [
|
||||
'深链、系统分享、即时本地通知',
|
||||
'重复执行支付、登录、系统分享、文件导入导出',
|
||||
'发布分享弹窗只有声明 `share.open` 时才显示“系统分享”',
|
||||
'发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作',
|
||||
]) {
|
||||
if (
|
||||
planSource.includes(staleShareWording) ||
|
||||
hostBridgeProtocolDocSource.includes(staleShareWording) ||
|
||||
decisionLogDocSource.includes(staleShareWording)
|
||||
) {
|
||||
throw new Error(
|
||||
`native shell docs must describe share.open as a host-specific controlled share action: ${staleShareWording}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const requiredShareWording of [
|
||||
'深链、受控分享动作、即时本地通知',
|
||||
'重复 `id` 不得重复执行支付、登录、受控分享动作、文件导入导出',
|
||||
'发布分享弹窗在 Expo 移动壳声明 `share.open` 时提供“系统分享”动作',
|
||||
'发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”',
|
||||
'并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达',
|
||||
]) {
|
||||
if (
|
||||
!planSource.includes(requiredShareWording) &&
|
||||
!hostBridgeProtocolDocSource.includes(requiredShareWording) &&
|
||||
!decisionLogDocSource.includes(requiredShareWording)
|
||||
) {
|
||||
throw new Error(
|
||||
`native shell docs missing host-specific share.open wording: ${requiredShareWording}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const staleMobilePermissionText of [
|
||||
'Android `permissions` 不手写显式权限',
|
||||
'最终 Expo public config 只允许扫码能力由 `expo-camera` plugin 带入 `android.permission.CAMERA`',
|
||||
'移动拍摄不请求麦克风权限',
|
||||
]) {
|
||||
if (
|
||||
planSource.includes(staleMobilePermissionText) ||
|
||||
hostBridgeProtocolDocSource.includes(staleMobilePermissionText)
|
||||
) {
|
||||
throw new Error(
|
||||
`native shell docs must not keep stale mobile permission wording: ${staleMobilePermissionText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertNativeShellScaffoldScanWording(planSource, 'native shell plan');
|
||||
assertNativeShellScaffoldScanWording(
|
||||
hostBridgeProtocolDocSource,
|
||||
'HostBridge protocol document',
|
||||
);
|
||||
assertNativeShellScaffoldScanWording(
|
||||
developmentWorkflowDocSource,
|
||||
'development workflow document',
|
||||
);
|
||||
assertNativeShellScaffoldScanWording(
|
||||
decisionLogDocSource,
|
||||
'decision log document',
|
||||
);
|
||||
assertShellLayerLayoutDocumented(planSource, 'native shell plan');
|
||||
assertShellLayerLayoutDocumented(
|
||||
hostBridgeProtocolDocSource,
|
||||
|
||||
@@ -1011,6 +1011,55 @@ async function runSmokeCases(
|
||||
`API 上游 X-Real-IP 未使用 TCP 对端 IP:${apiPayload.realIp}`,
|
||||
);
|
||||
|
||||
// 平台同源发行入口:/games/game_<32 位小写十六进制 id>/… 重写到发行网关并清空 Cookie,
|
||||
// 形状不符的路径不许被吞进发行网关(SPA 深链仍是 SPA,`/games/game/...` 仍是真实 404)。
|
||||
const releaseGameId = 'game_0123456789abcdef0123456789abcdef';
|
||||
const releaseUpstreamPath = `/api/game-distribution/releases/${releaseGameId}/index.html`;
|
||||
const releaseBeforeCount = api.state.requests.length;
|
||||
const releaseResponse = await expectHttp(
|
||||
baseUrl,
|
||||
`/games/${releaseGameId}/index.html`,
|
||||
200,
|
||||
'"upstream":"api"',
|
||||
'平台同源发行入口转发到发行网关',
|
||||
{
|
||||
headers: {
|
||||
'X-Request-Id': 'smoke-release-request-id',
|
||||
Host: 'example.test',
|
||||
Cookie: 'session=smoke-must-not-reach-release-gateway',
|
||||
},
|
||||
},
|
||||
);
|
||||
const releasePayload = JSON.parse(releaseResponse.body);
|
||||
ensure(
|
||||
releasePayload.url === releaseUpstreamPath,
|
||||
`发行入口上游路径没有重写:${releasePayload.url}`,
|
||||
);
|
||||
const releaseUpstreamRequests = api.state.requests.slice(releaseBeforeCount);
|
||||
ensure(
|
||||
releaseUpstreamRequests.length === 1 &&
|
||||
releaseUpstreamRequests[0].url === releaseUpstreamPath,
|
||||
`发行入口上游请求不符:${describeRequests(releaseUpstreamRequests)}`,
|
||||
);
|
||||
ensure(
|
||||
releaseUpstreamRequests[0]?.headers.cookie === undefined,
|
||||
`发行入口没有清空 Cookie:${describeRequests(releaseUpstreamRequests)}`,
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/games/detail',
|
||||
200,
|
||||
'site-shell',
|
||||
'发行入口形状不符时 SPA 深链照常回退',
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/games/game/index.html',
|
||||
404,
|
||||
'',
|
||||
'发行入口形状不符时仍是真实 404',
|
||||
);
|
||||
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/api/upload',
|
||||
@@ -1720,8 +1769,10 @@ async function expectConcurrencyLimit(baseUrl, api) {
|
||||
);
|
||||
} finally {
|
||||
api.state.releaseHold?.();
|
||||
hold.socket.end();
|
||||
// 先读完被放行的响应再关客户端连接:原先「释放 hold 后立刻 FIN」会让客户端半关闭
|
||||
// 与上游响应抢跑,Windows 上稳定表现为 `HTTP 响应提前关闭`(Linux 上只是偶尔更宽松)。
|
||||
const holdResponse = await hold.done.catch((error) => ({ error }));
|
||||
hold.socket.end();
|
||||
if (holdResponse?.error) {
|
||||
failures.push(
|
||||
`API 并发保护: hold 请求失败:${holdResponse.error.message}`,
|
||||
@@ -2519,9 +2570,12 @@ function resolveGatewayBinary() {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
// Windows 上 cargo 产出的是 `pingora-gateway.exe`;Linux 侧名字保持不变。
|
||||
const binaryName =
|
||||
process.platform === 'win32' ? 'pingora-gateway.exe' : 'pingora-gateway';
|
||||
const candidates = [
|
||||
path.join(repoRoot, 'server-rs', 'target', 'debug', 'pingora-gateway'),
|
||||
path.join(repoRoot, 'target', 'debug', 'pingora-gateway'),
|
||||
path.join(repoRoot, 'server-rs', 'target', 'debug', binaryName),
|
||||
path.join(repoRoot, 'target', 'debug', binaryName),
|
||||
];
|
||||
const found = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!found) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const PINGORA_GATEWAY_SOURCE = 'server-rs/crates/pingora-gateway/src/main.rs';
|
||||
const VALID_KINDS = new Set([
|
||||
'proxy',
|
||||
'static',
|
||||
'release_gateway',
|
||||
'redirect_permanent',
|
||||
'shadow_probe',
|
||||
'not_found',
|
||||
@@ -39,6 +40,9 @@ const REQUIRED_ROUTE_IDS = [
|
||||
'generated_assets_forbidden',
|
||||
'web_spa_fallback',
|
||||
'profile_spa_fallback',
|
||||
'games_spa_fallback',
|
||||
'games_release_gateway',
|
||||
'web_root_spa',
|
||||
'web_spa_case_trailing_slash',
|
||||
'web_unknown_path_exact',
|
||||
'creation_unknown_path_exact',
|
||||
@@ -108,6 +112,15 @@ function validateExpectation(route) {
|
||||
fail(`${context} 非 proxy 路由不能配置 protectionClass。`);
|
||||
}
|
||||
|
||||
if (expect.kind !== 'release_gateway' && hasOwn(expect, 'upstreamPath')) {
|
||||
fail(`${context} 只有 release_gateway 路由才能配置 upstreamPath。`);
|
||||
}
|
||||
|
||||
if (expect.kind === 'release_gateway') {
|
||||
requireString(expect.upstreamPath, `${context} upstreamPath`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.kind === 'static') {
|
||||
if (!VALID_STATIC_ROOTS.has(expect.root)) {
|
||||
fail(`${context} static root 不支持: ${expect.root}`);
|
||||
@@ -229,6 +242,28 @@ function validateRustTestUsesMatrix() {
|
||||
}
|
||||
}
|
||||
|
||||
// 反向覆盖:模板里出现的每条 location 都必须被矩阵某条用例声明过。
|
||||
// 只做正向检查(矩阵片段存在于模板)会漏掉「Nginx 模板加/改了路由、矩阵与 Pingora 没跟上」——
|
||||
// 2026-09-29 的发行网关路由就是这样:Nginx 有三份、Pingora 和矩阵都没有,门禁一直是绿的。
|
||||
function validateNginxLocationsAreCovered() {
|
||||
for (const environment of ['production', 'development']) {
|
||||
const source = files[environment];
|
||||
const locationFragments = matrix.routes
|
||||
.flatMap((route) => route.nginx?.[environment] ?? [])
|
||||
.map((fragment) => fragment.trim())
|
||||
.filter((fragment) => fragment.startsWith('location'));
|
||||
for (const match of source.matchAll(/^[ \t]*location\b[^\n]*/gmu)) {
|
||||
const line = match[0].trim().replace(/\s*\{\s*$/u, '');
|
||||
if (line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
if (!locationFragments.some((fragment) => line.startsWith(fragment))) {
|
||||
fail(`${environment} 模板的 location 没有被矩阵覆盖: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRustMainSpaRoutes() {
|
||||
const routeBlock = pingoraGatewaySource.match(
|
||||
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
|
||||
@@ -256,6 +291,7 @@ function validateRustMainSpaRoutes() {
|
||||
|
||||
validateMatrixShape();
|
||||
validateRustTestUsesMatrix();
|
||||
validateNginxLocationsAreCovered();
|
||||
validateRustMainSpaRoutes();
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -95,6 +96,9 @@ function main() {
|
||||
assertRealWechatPayUsesLastRefundReconciliationAssignment();
|
||||
assertDeployCleansStagingReleaseOnFailure();
|
||||
assertDeployRejectsFinalReleaseRaceAndCleansStaging();
|
||||
assertDeployPrunesOldReleases();
|
||||
assertDeployHonorsExplicitKeepReleases();
|
||||
assertDeployRejectsInvalidKeepReleases();
|
||||
assertMissingBackupScriptFails();
|
||||
assertMissingHealthPatrolScriptFails();
|
||||
assertMissingPingoraCurrentReleaseAuditFails();
|
||||
@@ -1754,6 +1758,106 @@ function assertDeployRejectsFinalReleaseRaceAndCleansStaging() {
|
||||
assertMaintenanceCleared(fixture, '目标 release 竞态失败');
|
||||
}
|
||||
|
||||
function assertDeployPrunesOldReleases() {
|
||||
const fixture = prepareFixture('prune-old-releases');
|
||||
const nowSeconds = Date.now() / 1000;
|
||||
const seededReleases = [
|
||||
['20260610-oldest', nowSeconds - 3000],
|
||||
['20260611-middle', nowSeconds - 2000],
|
||||
['20260613-previous', nowSeconds - 1000],
|
||||
];
|
||||
for (const [name, mtimeSeconds] of seededReleases) {
|
||||
const dir = path.join(fixture.releaseRoot, name);
|
||||
mkdirSync(path.join(dir, 'web'), { recursive: true });
|
||||
writeFileSync(path.join(dir, 'web', 'index.html'), `${name}\n`, 'utf8');
|
||||
utimesSync(dir, mtimeSeconds, mtimeSeconds);
|
||||
}
|
||||
// 发布根目录下不含 api-server/web 的目录不属于发布产物,清理时必须保留。
|
||||
const unrelatedDir = path.join(fixture.releaseRoot, 'dev-mcp-host-cache');
|
||||
mkdirSync(unrelatedDir, { recursive: true });
|
||||
writeFileSync(path.join(unrelatedDir, 'keep.txt'), 'keep\n', 'utf8');
|
||||
utimesSync(unrelatedDir, nowSeconds - 4000, nowSeconds - 4000);
|
||||
|
||||
const result = runDeploy(fixture);
|
||||
|
||||
assertStatus(result, 0, '存在历史 release 时完整 fixture 仍应部署成功。');
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
assertFileExists(
|
||||
path.join(fixture.releaseRoot, fixture.version),
|
||||
'本此发布目录必须保留。',
|
||||
);
|
||||
assertFileExists(
|
||||
path.join(fixture.releaseRoot, '20260613-previous'),
|
||||
'默认 --keep-releases 2 必须保留最近一个历史 release 以便回滚。',
|
||||
);
|
||||
for (const name of ['20260611-middle', '20260610-oldest']) {
|
||||
if (existsSync(path.join(fixture.releaseRoot, name))) {
|
||||
failures.push(
|
||||
`默认 --keep-releases 2 必须清理更早的历史 release: ${name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertFileExists(
|
||||
path.join(unrelatedDir, 'keep.txt'),
|
||||
'清理历史 release 不得删除发布根目录下不含 api-server/web 的目录。',
|
||||
);
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'清理历史 release:',
|
||||
'清理历史 release 时必须输出被清理的目录。',
|
||||
);
|
||||
}
|
||||
|
||||
function assertDeployHonorsExplicitKeepReleases() {
|
||||
const fixture = prepareFixture('keep-single-release');
|
||||
const nowSeconds = Date.now() / 1000;
|
||||
const previousReleaseDir = path.join(
|
||||
fixture.releaseRoot,
|
||||
'20260613-previous',
|
||||
);
|
||||
mkdirSync(path.join(previousReleaseDir, 'web'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(previousReleaseDir, 'web', 'index.html'),
|
||||
'previous\n',
|
||||
'utf8',
|
||||
);
|
||||
utimesSync(previousReleaseDir, nowSeconds - 1000, nowSeconds - 1000);
|
||||
|
||||
const result = runDeploy(fixture, { keepReleases: 1 });
|
||||
|
||||
assertStatus(result, 0, '--keep-releases 1 时完整 fixture 仍应部署成功。');
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
assertFileExists(
|
||||
path.join(fixture.releaseRoot, fixture.version),
|
||||
'--keep-releases 1 必须保留本次发布目录。',
|
||||
);
|
||||
if (existsSync(previousReleaseDir)) {
|
||||
failures.push('--keep-releases 1 必须清理除 current 以外的历史 release。');
|
||||
}
|
||||
}
|
||||
|
||||
function assertDeployRejectsInvalidKeepReleases() {
|
||||
const fixture = prepareFixture('invalid-keep-releases');
|
||||
const result = runDeploy(fixture, { keepReleases: '0' });
|
||||
|
||||
assertStatus(result, 1, '--keep-releases 0 必须被拒绝。');
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'--keep-releases 必须是大于等于 1 的整数',
|
||||
'--keep-releases 非法时必须给出明确错误。',
|
||||
);
|
||||
assertNotIncludes(
|
||||
readOptionalCommandsLog(fixture),
|
||||
'systemctl restart genarrative-api.service',
|
||||
'--keep-releases 非法时不得重启 API 服务。',
|
||||
);
|
||||
assertNoReleasePromoted(fixture, '--keep-releases 非法时不得提升 release');
|
||||
}
|
||||
|
||||
function assertMissingPingoraDirectCheckFails() {
|
||||
const fixture = prepareFixture('missing-direct-live');
|
||||
rmSync(path.join(fixture.sourceDir, 'scripts/check-pingora-direct-live.mjs'));
|
||||
@@ -2785,6 +2889,9 @@ function runDeploy(fixture, options = {}) {
|
||||
if (options.keepMaintenance) {
|
||||
args.push('--keep-maintenance-mode');
|
||||
}
|
||||
if (options.keepReleases !== undefined) {
|
||||
args.push('--keep-releases', String(options.keepReleases));
|
||||
}
|
||||
return spawnSync('bash', args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
|
||||
@@ -107,6 +107,18 @@ const checks = [
|
||||
includes: 'npm run lint:eslint',
|
||||
reason: 'Web 生产构建必须执行 ESLint 门禁。',
|
||||
},
|
||||
{
|
||||
file: 'package.json',
|
||||
includes: 'npm run check:nginx-spa-routes',
|
||||
reason:
|
||||
'仓库 lint 链必须包含主站 SPA allowlist 门禁:否则模板与前端路由源漂移只在人工执行时才可见(2026-08-26 起红了一个月就是没有调用方)。',
|
||||
},
|
||||
{
|
||||
file: 'package.json',
|
||||
includes: 'npm run check:pingora-route-parity',
|
||||
reason:
|
||||
'仓库 lint 链必须包含 Nginx/Pingora 路由矩阵 parity 门禁:否则模板新增 location 而矩阵与网关没跟上时,门禁不会在 CI 里跑(发行网关路由就是这么漏的)。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-web-build',
|
||||
includes: 'npm run typecheck',
|
||||
@@ -428,6 +440,24 @@ const checks = [
|
||||
'--bgfilter-worker-env-file "${BGFILTER_WORKER_ENV_FILE:-/etc/genarrative/bgfilter-worker.env}"',
|
||||
reason: 'API Deploy Job 必须把 BgFilter worker env 路径传给发布脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-api-deploy',
|
||||
includes: '[staging-cleanup]',
|
||||
reason:
|
||||
'API Deploy Job 必须清理历史 build/<version> 暂存,避免目标机被 copyArtifacts 暂存撑满。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-web-deploy',
|
||||
includes: '[staging-cleanup]',
|
||||
reason:
|
||||
'Web Deploy Job 必须清理历史 build/<version> 暂存,避免目标机被 copyArtifacts 暂存撑满。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: '[staging-cleanup]',
|
||||
reason:
|
||||
'Stdb Publish Job 必须清理历史 build/<version> 暂存,避免目标机被 copyArtifacts 暂存撑满。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
@@ -475,6 +505,17 @@ const checks = [
|
||||
reason:
|
||||
'API deploy 必须在 current 切换前复核真实微信支付退款 reconciliation。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: 'prune_old_releases',
|
||||
reason:
|
||||
'API deploy 必须在发布成功后按 --keep-releases 清理历史 release 目录,避免目标机根盘被历史发布撑满。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: '--keep-releases 必须是大于等于 1 的整数',
|
||||
reason: 'API deploy 必须拒绝非法的 --keep-releases 参数。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes:
|
||||
@@ -2014,12 +2055,6 @@ const checks = [
|
||||
reason:
|
||||
'Pingora 试点文档必须记录 realpath canary 的 Nginx log_format 加载顺序踩坑。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: 'Pingora realpath canary include 要晚于 log_format',
|
||||
reason:
|
||||
'团队共享踩坑必须记录 realpath canary 独立 server 在 conf.d 中的加载顺序要求。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/jenkins-server-provision.sh',
|
||||
includes: 'genarrative-health-patrol.timer',
|
||||
@@ -3308,16 +3343,6 @@ const checks = [
|
||||
includes: 'manifest.files` 视为闭集',
|
||||
reason: '生产运维文档必须说明证据验真默认闭集归档口径。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: 'manifest.files` 视为闭集',
|
||||
reason: '团队共享决策必须记录 Pingora 证据验真闭集归档口径。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: 'manifest.files` 视为闭集',
|
||||
reason: '团队共享踩坑必须记录 Pingora 证据目录不能夹带未登记条目。',
|
||||
},
|
||||
{
|
||||
file: 'docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md',
|
||||
includes: 'pingora-gateway-env-shadow-switch.mjs --apply',
|
||||
@@ -3330,18 +3355,6 @@ const checks = [
|
||||
reason:
|
||||
'生产运维文档必须记录 rollback apply 前用随包脚本恢复 Pingora shadow env。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: 'pingora-gateway-env-shadow-switch.mjs --apply',
|
||||
reason:
|
||||
'团队共享决策必须记录 rollback apply 前用随包脚本恢复 Pingora shadow env。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: 'pingora-gateway-env-shadow-switch.mjs --apply',
|
||||
reason:
|
||||
'团队共享踩坑必须记录 rollback apply 前用随包脚本恢复 Pingora shadow env。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/nginx/README.md',
|
||||
includes: 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
|
||||
@@ -6731,39 +6744,6 @@ const checks = [
|
||||
reason:
|
||||
'Nginx README 必须说明 direct live 静态响应头摘要缺关键证据时会阻断证据包。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: 'API / WSS 检查不落原始响应头',
|
||||
reason: '团队共享决策必须记录 direct live 只归档静态白名单响应头。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: 'directLiveStaticHeaders',
|
||||
reason:
|
||||
'团队共享决策必须记录切流 manifest 提升 direct live 静态响应头摘要。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes:
|
||||
'静态头摘要缺少缓存头、校验头、Range `206 + Content-Range`、ETag 304 / Last-Modified 304 证据时整包记为 `CRITICAL`',
|
||||
reason: '团队共享决策必须记录静态响应头摘要不完整会阻断证据包。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: '不要把 `direct-live.json` 当成只有状态码的摘要',
|
||||
reason: '团队共享踩坑必须提醒 direct live JSON 需要可复盘静态响应头证据。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: 'manifest.summary.directLiveStaticHeaders',
|
||||
reason: '团队共享踩坑必须提醒切流 manifest 需要可快速复盘静态响应头摘要。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes:
|
||||
'摘要缺少缓存头、校验头、Range `206 + Content-Range`、ETag 304 或 Last-Modified 304 证据,证据包会直接记为 `CRITICAL`',
|
||||
reason: '团队共享踩坑必须提醒静态响应头摘要不完整会阻断证据包。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/ops/pingora-cutover-evidence-bundle.mjs',
|
||||
includes: 'directLiveAccessLog',
|
||||
@@ -6991,11 +6971,6 @@ const checks = [
|
||||
excludes: '补齐 Brotli 取舍',
|
||||
reason: 'Pingora 试点文档不能继续把 Brotli 取舍留成待办。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: 'Pingora Brotli 不能只看 Content-Encoding',
|
||||
reason: '团队共享踩坑必须记录 Pingora Brotli 端到端解压风险。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/pingora/pingora-gateway.env.example',
|
||||
includes: 'GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL=5',
|
||||
@@ -7096,17 +7071,6 @@ const checks = [
|
||||
'--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log',
|
||||
reason: '生产运维文档必须展示 Pingora 直连 access log 落盘校验参数。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: '直连日志证据补充',
|
||||
reason: '团队共享决策必须记录 Pingora 直连 access log 证据门禁。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes:
|
||||
'--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log',
|
||||
reason: '团队共享踩坑必须记录 Pingora 直连 access log 参数。',
|
||||
},
|
||||
{
|
||||
file: 'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md',
|
||||
includes:
|
||||
@@ -7151,26 +7115,6 @@ const checks = [
|
||||
includes: '不要继续用固定 `http://127.0.0.1/healthz` 与 `"ok":true`',
|
||||
reason: 'Nginx README 必须防止回退 smoke 示例回到旧 healthz 口径。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: '不再默认假定 `/healthz` 会返回 `"ok":true`',
|
||||
reason: '团队共享决策必须记录回退 smoke 的真实 Nginx 入口要求。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: '启用后复核应看到端口由 Pingora 占用而不是空闲',
|
||||
reason: '团队共享决策必须记录启用后 --require-direct 不再检查端口空闲。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: '不要继续用固定 `/healthz` 与 `"ok":true`',
|
||||
reason: '团队共享踩坑必须提醒回退 smoke 不能沿用旧 healthz 片段。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: '启用后 `--require-direct` 复核不再要求端口空闲',
|
||||
reason: '团队共享踩坑必须提醒端口空闲只属于切流前门禁。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/nginx/README.md',
|
||||
excludes:
|
||||
@@ -7193,12 +7137,6 @@ const checks = [
|
||||
excludes: 'rollback-nginx-smoke-expect-body="ok":true',
|
||||
reason: 'Pingora 试点 runbook 不应再默认展示 ok:true 作为回退响应体片段。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
excludes:
|
||||
'--nginx-smoke-url http://127.0.0.1/healthz --nginx-smoke-host <域名>',
|
||||
reason: '团队共享踩坑不应再把固定本机 healthz 当成可执行回退示例。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/pingora/pingora-gateway.env.example',
|
||||
includes: 'GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED',
|
||||
@@ -7231,27 +7169,6 @@ const checks = [
|
||||
reason:
|
||||
'生产运维文档必须明确多实例 Pingora 与进程内保护的启动 / preflight 边界。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: 'GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT>1',
|
||||
reason: '团队共享决策必须记录 Pingora 多实例接流保护边界。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/decision-log.md',
|
||||
includes: '禁止继续执行部署工作区根部脚本',
|
||||
reason: '团队共享决策必须记录 API Deploy 不再执行 workspace 根部脚本。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: 'API deploy 脚本本身也不能继续用部署工作区根部',
|
||||
reason:
|
||||
'团队共享踩坑必须记录 workspace 根部 deploy 脚本会掩盖发布包布局问题。',
|
||||
},
|
||||
{
|
||||
file: 'docs/project-memory/shared-memory/pitfalls.md',
|
||||
includes: '备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底',
|
||||
reason: '团队共享踩坑必须记录备份/巡检脚本也不能使用 workspace fallback。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/nginx/README.md',
|
||||
includes: 'Pingora 影子网关已通过',
|
||||
@@ -7794,6 +7711,56 @@ for (const [file, content] of [
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新签名私钥只允许经 Jenkins 凭据绑定注入构建进程:既不得把凭据本身写进 Jenkinsfile,
|
||||
// 也不得回显到日志或进归档(对应「AGC 更新发布管线渠道化」第 5 条的可静态复核部分)。
|
||||
for (const [file, content] of [
|
||||
['jenkins/Jenkinsfile.ai-game-creator-shell-build', agcPipelineContent],
|
||||
[
|
||||
'jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
|
||||
agcMacosPipelineContent,
|
||||
],
|
||||
]) {
|
||||
for (const [snippet, reason] of [
|
||||
[
|
||||
"string(credentialsId: 'AgcUpdaterSigningKey', variable: 'TAURI_SIGNING_PRIVATE_KEY')",
|
||||
'签名私钥必须经 `AgcUpdaterSigningKey` 凭据绑定注入 `TAURI_SIGNING_PRIVATE_KEY`。',
|
||||
],
|
||||
[
|
||||
"string(credentialsId: 'AgcUpdaterSigningKeyPassword', variable: 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD')",
|
||||
'私钥口令必须经 `AgcUpdaterSigningKeyPassword` 凭据绑定注入。',
|
||||
],
|
||||
]) {
|
||||
if (!content.includes(snippet)) {
|
||||
failed = true;
|
||||
console.error(`[check:production-ops] ${file} ${reason}`);
|
||||
}
|
||||
}
|
||||
for (const leak of [
|
||||
'echo $TAURI_SIGNING_PRIVATE_KEY',
|
||||
'echo "$TAURI_SIGNING_PRIVATE_KEY"',
|
||||
'echo ${TAURI_SIGNING_PRIVATE_KEY}',
|
||||
'echo "$env:TAURI_SIGNING_PRIVATE_KEY"',
|
||||
'Write-Host $env:TAURI_SIGNING_PRIVATE_KEY',
|
||||
'Write-Output $env:TAURI_SIGNING_PRIVATE_KEY',
|
||||
'%TAURI_SIGNING_PRIVATE_KEY%',
|
||||
'set -x',
|
||||
]) {
|
||||
if (content.includes(leak)) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`[check:production-ops] ${file} 不得把签名凭据打印到构建日志(命中 \`${leak}\`,xtrace 会把绑定的凭据一起打出来)。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const archiveBlock = content.split('archiveArtifacts')[1] ?? '';
|
||||
if (/(\.pem|\.key|\.pfx|\.p12)\b/u.test(archiveBlock.slice(0, 600))) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`[check:production-ops] ${file} 归档 glob 不得包含签名私钥类文件(*.pem / *.key / *.pfx / *.p12)。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [snippet, reason] of [
|
||||
[
|
||||
"string(name: 'OSS_DOWNLOAD_URL'",
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--bgfilter-worker-service genarrative-bgfilter-worker.service] [--bgfilter-worker-health-url <url>] [--bgfilter-worker-env-file /etc/genarrative/bgfilter-worker.env] [--no-bgfilter-worker] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--controller-env-file /etc/genarrative/external-generation-controller.env] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] [--keep-maintenance-mode]
|
||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--bgfilter-worker-service genarrative-bgfilter-worker.service] [--bgfilter-worker-health-url <url>] [--bgfilter-worker-env-file /etc/genarrative/bgfilter-worker.env] [--no-bgfilter-worker] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--controller-env-file /etc/genarrative/external-generation-controller.env] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] [--keep-maintenance-mode] [--keep-releases 2]
|
||||
|
||||
说明:
|
||||
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
||||
@@ -16,6 +16,7 @@ usage() {
|
||||
若发布包包含 pingora-gateway,或传入 --require-pingora-gateway,部署脚本会要求 release manifest、二进制与 checksum 一致,再在 current 链接切换后先复核 systemd/env 仍是本机高端口 shadow 配置,启动或重启 Pingora 影子服务并复核 active。
|
||||
默认在 readiness 通过后退出维护模式;传入 --keep-maintenance-mode 时保留维护文件,供人工验收后再恢复公网。
|
||||
current 链接切换前失败时会退出本次打开的维护模式;current 链接切换后失败时保留维护模式,避免暴露半发布版本。
|
||||
发布成功后按 --keep-releases(默认 2)保留 current 目标与最近的历史 release 目录,清理其它含 api-server 或 web 的旧发布目录,避免目标机根盘被历史发布撑满;该清理失败只告警,不改变本次发布结论。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -1177,6 +1178,7 @@ DEPLOY_COMPLETED=0
|
||||
PINGORA_INCLUDED=0
|
||||
REQUIRE_PINGORA_GATEWAY=0
|
||||
KEEP_MAINTENANCE_MODE=0
|
||||
KEEP_RELEASES=2
|
||||
MAINTENANCE_ENABLED_BY_DEPLOY=0
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
CURRENT_LINK_SWITCHED=0
|
||||
@@ -1222,6 +1224,10 @@ while [[ $# -gt 0 ]]; do
|
||||
KEEP_MAINTENANCE_MODE=1
|
||||
shift
|
||||
;;
|
||||
--keep-releases)
|
||||
KEEP_RELEASES="${2:?缺少 --keep-releases 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--worker-service-pattern)
|
||||
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
|
||||
shift 2
|
||||
@@ -1290,6 +1296,10 @@ done
|
||||
require_argument "${SOURCE_DIR}" "--source-dir"
|
||||
require_absolute_path "${RELEASE_ROOT}" "--release-root"
|
||||
require_absolute_path "${CURRENT_LINK}" "--current-link"
|
||||
if [[ ! "${KEEP_RELEASES}" =~ ^[0-9]+$ ]] || (( KEEP_RELEASES < 1 )); then
|
||||
echo "[production-api-deploy] --keep-releases 必须是大于等于 1 的整数: ${KEEP_RELEASES}" >&2
|
||||
exit 1
|
||||
fi
|
||||
require_absolute_path "${API_ENV_FILE}" "--api-env-file"
|
||||
if [[ -n "${WORKER_ENV_FILE}" ]]; then
|
||||
require_absolute_path "${WORKER_ENV_FILE}" "--worker-env-file"
|
||||
@@ -1359,6 +1369,44 @@ cleanup_rendered_systemd_unit() {
|
||||
RENDERED_SYSTEMD_UNIT_FILE=""
|
||||
}
|
||||
|
||||
# 保留 current 目标与最近 keep_total-1 个其它发布目录。
|
||||
# 只清理同时不是符号链接、名称不以点开头且含 api-server 或 web 的目录,
|
||||
# 避免误删发布根目录下不属于发布产物的目录。
|
||||
prune_old_releases() {
|
||||
local keep_total="$1"
|
||||
local current_target=""
|
||||
local keep_others=$(( keep_total - 1 ))
|
||||
local kept_others=0
|
||||
local candidate resolved
|
||||
|
||||
if [[ -L "${CURRENT_LINK}" ]]; then
|
||||
current_target="$(readlink -f "${CURRENT_LINK}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
while IFS= read -r candidate; do
|
||||
[[ -n "${candidate}" ]] || continue
|
||||
case "${candidate}" in
|
||||
*[[:space:]]*) continue ;;
|
||||
esac
|
||||
[[ -d "${candidate}" && ! -L "${candidate}" ]] || continue
|
||||
[[ -e "${candidate}/api-server" || -e "${candidate}/web" ]] || continue
|
||||
resolved="$(readlink -f "${candidate}")"
|
||||
if [[ -n "${current_target}" && "${resolved}" == "${current_target}" ]]; then
|
||||
continue
|
||||
fi
|
||||
if (( kept_others < keep_others )); then
|
||||
kept_others=$(( kept_others + 1 ))
|
||||
continue
|
||||
fi
|
||||
echo "[production-api-deploy] 清理历史 release: ${candidate}"
|
||||
rm -rf --one-file-system "${resolved}"
|
||||
done < <(
|
||||
find "${RELEASE_ROOT}" -mindepth 1 -maxdepth 1 -type d -name '[!.]*' -printf '%T@ %p\n' 2>/dev/null |
|
||||
sort -rn |
|
||||
cut -d' ' -f2-
|
||||
)
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
local exit_code=$?
|
||||
cleanup_rendered_systemd_unit
|
||||
@@ -1697,6 +1745,8 @@ for _ in {1..30}; do
|
||||
bash "${SCRIPT_DIR}/maintenance-off.sh"
|
||||
fi
|
||||
DEPLOY_COMPLETED=1
|
||||
prune_old_releases "${KEEP_RELEASES}" ||
|
||||
echo "[production-api-deploy] 历史 release 清理失败(仅影响磁盘占用,不影响本次发布结论)" >&2
|
||||
echo "[production-api-deploy] 完成: ${RELEASE_DIR}/api-server"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -277,11 +277,13 @@ test('hook fixtures do not mutate the calling linked worktree or its index', ()
|
||||
writeFileSync(join(worktree, 'sentinel.txt'), 'unstaged\n');
|
||||
const gitDir = git(worktree, 'rev-parse', '--absolute-git-dir').trim();
|
||||
const indexPath = join(gitDir, 'index');
|
||||
// `git status` 会刷新并重写索引里的 stat 缓存(macOS 与部分 git 版本上字节必然变化),
|
||||
// 所以先跑完会写索引的探针再取快照,否则断言的是探针自身的副作用而不是被测行为。
|
||||
const before = {
|
||||
refs: git(outerRepo, 'show-ref'),
|
||||
config: readFileSync(join(outerRepo, '.git', 'config'), 'utf8'),
|
||||
index: readFileSync(indexPath),
|
||||
status: git(worktree, 'status', '--porcelain'),
|
||||
index: readFileSync(indexPath),
|
||||
};
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user