// AGC 项目快照「后台列表 + 工程 ZIP 下载」的真实链路检查。 // // 用法(需要本地 dev 栈与一份已经同步过的项目副本): // E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ // E2E_SNAPSHOT_USER_ID=<快照归属 user id> E2E_SNAPSHOT_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); });