Files
Genarrative/scripts/check-agc-project-snapshot-admin-http.mjs
T
kdletters 2d875377a9
Project CI / AI game creator shell Rust crates (push) Successful in 1m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m58s
Project CI / Backend tests (push) Successful in 5m36s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
补齐项目快照的客户端上传与后台列表下载联调取证
- 新增 scripts/check-agc-project-snapshot-admin-http.mjs:核对后台列表里的快照统计、下载工程 ZIP、逐条与本地项目副本比对 SHA-256,并检查排除项与 .agent 目录
- 里程碑条目 9 改为已勾选,写入真实客户端共享模块上传(首次 synced、紧接着 no-op)与后台列表/ZIP 的 HTTP 联调取证,并把未验证项单独列出
- pitfalls 记录单用户每小时 3000 次文件上传配额对真实链路冒烟的影响与选项目口径
2026-09-28 19:35:22 +08:00

218 lines
7.1 KiB
JavaScript
Raw Blame History

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