import { existsSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; const repoRoot = process.cwd(); const defaultStatePath = join(repoRoot, '.app', 'dev-stack.json'); const args = process.argv.slice(2); const failures = []; const warnings = []; const options = parseArgs(args); if (options.help) { printUsage(); process.exit(0); } if (options.checklistOnly) { printManualChecklist(); process.exit(0); } const state = readDevStackState(options.statePath); if (state) { await checkDevStack(state); } if (warnings.length > 0) { console.warn('\n[editor-agent-p2-smoke] 提醒:'); for (const warning of warnings) { console.warn(`- ${warning}`); } } if (failures.length > 0) { console.error('\n[editor-agent-p2-smoke] 未通过:'); for (const failure of failures) { console.error(`- ${failure}`); } console.error( '\n先用 `npm run dev -- --no-interactive --api-timeout-seconds 900` 启动完整栈,等待 SpacetimeDB publish、api-server /healthz 和 P2 runtime worker 就绪后再重跑。', ); process.exit(1); } console.log('\n[editor-agent-p2-smoke] dev 栈就绪检查通过。'); printManualChecklist(state); function parseArgs(rawArgs) { const parsed = { checklistOnly: false, help: false, statePath: defaultStatePath, }; for (let index = 0; index < rawArgs.length; index += 1) { const arg = rawArgs[index]; switch (arg) { case '--checklist-only': parsed.checklistOnly = true; break; case '--state-path': { const value = rawArgs[index + 1]; if (!value || value.startsWith('--')) { throw new Error('--state-path 缺少路径参数'); } parsed.statePath = resolve(repoRoot, value); index += 1; break; } case '-h': case '--help': parsed.help = true; break; default: throw new Error(`未知参数:${arg}`); } } return parsed; } function printUsage() { console.log(`用法: npm run check:editor-agent-p2-smoke npm run check:editor-agent-p2-smoke -- --checklist-only 说明: 默认读取 .app/dev-stack.json,检查 SpacetimeDB / api-server / web / preview gateway 已可访问。 完整 npm run dev 会为 api-server 注入 GENARRATIVE_PROCESS_ROLE=all,以便同进程消费 P2 Web Project runtime job。 --checklist-only 只打印真实浏览器 smoke 步骤,不要求本地 dev 栈正在运行。 `); } function readDevStackState(statePath) { if (!existsSync(statePath)) { failures.push(`缺少 dev 栈状态文件:${statePath}`); return null; } try { const state = JSON.parse(readFileSync(statePath, 'utf8')); if (state.repoRoot && resolve(state.repoRoot) !== resolve(repoRoot)) { failures.push( `.app/dev-stack.json 属于其它工作区:${state.repoRoot},当前为 ${repoRoot}`, ); } return state; } catch (error) { failures.push(`读取 dev 栈状态失败:${error.message}`); return null; } } async function checkDevStack(state) { if (state.command !== 'all') { warnings.push( `当前 dev 命令为 ${state.command ?? 'unknown'};P2 浏览器 smoke 建议使用完整 npm run dev,或确保拆分终端已启动 web-project-runtime-worker 并携带同一 SpacetimeDB token。`, ); } const spacetimeUrl = getServiceUrl(state, 'spacetime'); const apiUrl = getServiceUrl(state, 'api-server'); const webUrl = getServiceUrl(state, 'web'); const previewUrl = getServiceUrl(state, 'web-project-preview'); await Promise.all([ spacetimeUrl ? expectHttp({ label: 'SpacetimeDB /v1/ping', url: joinUrl(spacetimeUrl, '/v1/ping'), accept: (response) => response.ok, }) : Promise.resolve(), apiUrl ? expectJson({ label: 'api-server /healthz', url: joinUrl(apiUrl, '/healthz'), validate: (json) => json && json.ok === true && json.service === 'genarrative-api-server', }) : Promise.resolve(), webUrl ? expectHttp({ label: 'web /editor/agent', url: joinUrl(webUrl, '/editor/agent'), accept: (response) => response.status >= 200 && response.status < 500, }) : Promise.resolve(), previewUrl ? expectHttp({ label: 'preview gateway invalid token guard', url: joinUrl(previewUrl, '/p/not-a-valid-preview-token/'), accept: (response) => response.status === 410, }) : Promise.resolve(), ]); } function getServiceUrl(state, serviceName) { const service = state.services?.[serviceName]; const url = String(service?.url ?? '').trim(); if (!url) { failures.push(`dev 栈状态缺少 ${serviceName}.url`); return ''; } const status = String(service?.status ?? '').trim(); const expectedStatus = serviceName === 'spacetime' ? ['running', 'reused'] : ['running']; if (!expectedStatus.includes(status)) { warnings.push(`${serviceName} 状态为 ${status || 'unknown'},将继续以 HTTP 探测为准。`); } return url; } async function expectJson({ label, url, validate }) { const response = await fetchForSmoke(label, url); if (!response) { return; } if (!response.ok) { failures.push(`${label} 返回 HTTP ${response.status}:${url}`); return; } try { const json = await response.json(); if (!validate(json)) { failures.push(`${label} 响应 JSON 不符合预期:${JSON.stringify(json)}`); } else { console.log(`[editor-agent-p2-smoke] OK ${label}: ${url}`); } } catch (error) { failures.push(`${label} 响应不是合法 JSON:${error.message}`); } } async function expectHttp({ label, url, accept }) { const response = await fetchForSmoke(label, url); if (!response) { return; } if (!accept(response)) { failures.push(`${label} 返回 HTTP ${response.status}:${url}`); return; } console.log(`[editor-agent-p2-smoke] OK ${label}: ${url}`); } async function fetchForSmoke(label, url) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { return await fetch(url, { redirect: 'manual', signal: controller.signal, }); } catch (error) { failures.push(`${label} 无法访问:${url} (${error.message})`); return null; } finally { clearTimeout(timer); } } function joinUrl(baseUrl, path) { return new URL(path, `${baseUrl.replace(/\/+$/u, '')}/`).href; } function printManualChecklist(state = null) { const webUrl = String(state?.services?.web?.url ?? '').trim(); const editorUrl = webUrl ? joinUrl(webUrl, '/editor/agent') : '/editor/agent'; console.log(` [editor-agent-p2-smoke] 真实浏览器最小 smoke: 1. 打开 ${editorUrl} 2. 提交“做一个蓝色计数按钮页面”,等待 runtime job 进入 succeeded。 3. 确认 iframe 显示蓝色计数按钮,并点击后从“已点击 0 次”变为“已点击 1 次”。 4. 提交“破坏构建”,确认新 job failed 且上一版成功 preview URL 不变。 5. 连续提交两次计数按钮改动,确认旧 snapshot job 不覆盖最新 active preview。 6. 对一个 queued 或 running runtime job 点击取消,确认状态收敛为 cancelled。 7. 刷新页面,确认 project、active snapshot、active preview、job 状态和日志从后端恢复。 提示:完整 npm run dev 会自动使用 GENARRATIVE_PROCESS_ROLE=all;拆分终端手动启动 worker 时必须复用本轮 dev 脚本创建的 GENARRATIVE_SPACETIME_TOKEN。 `); }