补齐项目快照的客户端上传与后台列表下载联调取证
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
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 次文件上传配额对真实链路冒烟的影响与选项目口径
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user