新增 Web Project runtime job、持久日志、lease、取消、expired、stale 和 active preview guard 状态机 接入 api-server Web Project runtime worker 与 TempDirBuildRuntime 构建执行链路 补齐 SpacetimeDB procedure、spacetime-client facade、shared contracts 和前端 web-project client 契约 更新 /editor/agent 的 runtime job 恢复、日志回填、SSE 重连、取消按钮和 active preview 刷新恢复 新增 P2 dev smoke 脚本,并让完整 npm run dev 默认以 all 角色启动 P2 worker 补充 P2 自动化测试、浏览器 smoke 验收记录、开发运维文档和 Hermes 踩坑记忆
246 lines
7.5 KiB
JavaScript
246 lines
7.5 KiB
JavaScript
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。
|
||
`);
|
||
}
|