#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const { BASE_URL, REQUEST_TIMEOUT_MS, UI_TIMEOUT_MS, cleanupDocuments, createTempDocument, ensureAuthenticated, renameDocument, requestJson, } = require("./tree-shell-smoke-helpers"); const OUT_DIR = path.join(process.cwd(), "tmp", "acp-multi-session-stability-smoke"); const PROFILE = process.env.MNOTE_ACP_STABILITY_PROFILE || "reasonix"; const RUNTIME = process.env.MNOTE_ACP_STABILITY_RUNTIME || ""; const RUN_COUNT = Math.max(1, Number(process.env.MNOTE_ACP_STABILITY_RUN_COUNT || 3)); const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_ACP_STABILITY_STREAM_TIMEOUT_MS || 60_000); const CANCEL_INDEX = Math.min(Math.max(0, Number(process.env.MNOTE_ACP_STABILITY_CANCEL_INDEX || 1)), RUN_COUNT - 1); function nowSuffix() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } async function createHermesSession(request, target, suffix, index) { return requestJson(request, "/api/hermes/client/sessions", { method: "POST", headers: { "x-mnote-actor-id": "acp-smoke-user" }, data: { workspaceId: target.workspaceId, documentId: target.documentId, title: `ACP 稳定性 ${suffix} #${index + 1}`, profile: PROFILE, traceId: `trace_acp_session_${suffix}_${index}`, }, }); } async function createHermesRun(request, target, sessionId, suffix, index) { const payload = { workspaceId: target.workspaceId, documentId: target.documentId, sessionId, profile: PROFILE, message: index === CANCEL_INDEX ? `请用中文逐条列出 1 到 200 的检查项,每条都包含 ACP 多会话 smoke ${suffix},不要修改文件。` : `请用一句中文回复:ACP 多会话 smoke ${suffix} 第 ${index + 1} 条。不要修改文件。`, contextScope: "summary", traceId: `trace_acp_run_${suffix}_${index}`, actorId: "acp-smoke-user", }; if (RUNTIME) { payload.acpRuntime = RUNTIME; } return requestJson(request, "/api/hermes/client/runs", { method: "POST", headers: { "x-mnote-actor-id": "acp-smoke-user" }, data: payload, }); } async function abortRun(request, runId) { return requestJson(request, `/api/hermes/client/runs/${encodeURIComponent(runId)}/abort`, { method: "POST", headers: { "x-mnote-actor-id": "acp-smoke-user" }, data: { reason: "task488_acp_stability_smoke_cancel" }, }); } function parseSseChunk(text, events) { for (const frame of text.split(/\n\n+/)) { const lines = frame.split(/\n/).filter(Boolean); if (lines.length === 0) continue; let event = "message"; const dataLines = []; for (const line of lines) { if (line.startsWith("event:")) { event = line.slice("event:".length).trim() || "message"; } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } } if (dataLines.length === 0) continue; const dataText = dataLines.join("\n"); let data = dataText; try { data = JSON.parse(dataText); } catch { // 保留原始文本,便于排障。 } events.push({ event, data }); } } async function cookieHeaderForContext(context) { const cookies = await context.cookies(BASE_URL); return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); } async function streamRunEvents(runId, label, cookieHeader) { const events = []; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS); try { const response = await fetch(`${BASE_URL}/api/hermes/client/events/${encodeURIComponent(runId)}`, { headers: { "x-mnote-actor-id": "acp-smoke-user", ...(cookieHeader ? { cookie: cookieHeader } : {}), }, signal: controller.signal, }); if (!response.ok) { const text = await response.text(); throw new Error(`${label} events 请求失败: ${response.status} ${response.statusText} ${text.slice(0, 1200)}`); } const decoder = new TextDecoder(); const reader = response.body.getReader(); let raw = ""; let pending = ""; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); raw += chunk; pending += chunk; const frames = pending.split(/\n\n+/); pending = frames.pop() || ""; if (frames.length > 0) { parseSseChunk(frames.join("\n\n"), events); } if (terminalEvent(events)) { await reader.cancel().catch(() => undefined); break; } } const rest = decoder.decode(); if (rest) { raw += rest; pending += rest; } if (pending.trim()) { parseSseChunk(pending, events); } if (!terminalEvent(events)) { throw new Error(`${label} events 未收到 terminal event`); } return { runId, status: response.status, events, raw }; } finally { clearTimeout(timeout); controller.abort(); } } function terminalEvent(events) { return events.find((entry) => entry.event === "run.completed" || entry.event === "run.failed" || entry.event === "run.aborted"); } async function main() { const suffix = nowSuffix(); const title = `TEST-ACP-STABILITY-${suffix}`; const createdIds = []; const evidence = { ok: false, baseUrl: BASE_URL, profile: PROFILE, runtime: RUNTIME || null, runCount: RUN_COUNT, cancelIndex: CANCEL_INDEX, title, sessions: [], runs: [], streams: [], }; await fs.mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); try { await ensureAuthenticated(page, context.request); const target = await createTempDocument(context.request); createdIds.push(target.documentId); evidence.documentId = target.documentId; evidence.workspaceId = target.workspaceId; await renameDocument(context.request, target.workspaceId, target.documentId, title); const sessions = []; for (let index = 0; index < RUN_COUNT; index += 1) { const session = await createHermesSession(context.request, target, suffix, index); assert(session.sessionId, `session ${index + 1} 缺少 sessionId`); sessions.push(session); evidence.sessions.push({ index, sessionId: session.sessionId, persistence: session.persistence || null, profile: session.profile || PROFILE, }); } const runs = await Promise.all( sessions.map((session, index) => createHermesRun(context.request, target, session.sessionId, suffix, index)), ); for (let index = 0; index < runs.length; index += 1) { assert(runs[index].runId, `run ${index + 1} 缺少 runId`); evidence.runs.push({ index, runId: runs[index].runId, sessionId: runs[index].sessionId, persistence: runs[index].persistence || null, runtime: runs[index].runtime || null, }); } const cookieHeader = await cookieHeaderForContext(context); const streamPromises = runs.map((run, index) => streamRunEvents(run.runId, `run ${index + 1}`, cookieHeader)); await new Promise((resolve) => setTimeout(resolve, 500)); const abortPayload = await abortRun(context.request, runs[CANCEL_INDEX].runId); evidence.abort = { runId: runs[CANCEL_INDEX].runId, status: abortPayload.status, ok: abortPayload.ok, events: abortPayload.events || [], }; const streamResults = await Promise.allSettled(streamPromises); const rejectedStreams = streamResults .map((result, index) => ({ result, index })) .filter(({ result }) => result.status === "rejected"); if (rejectedStreams.length > 0) { evidence.streamErrors = rejectedStreams.map(({ result, index }) => ({ index, runId: runs[index].runId, message: result.reason instanceof Error ? result.reason.message : String(result.reason), stack: result.reason instanceof Error ? result.reason.stack : null, })); throw new Error(`ACP SSE 读取失败: ${evidence.streamErrors.map((item) => `run ${item.index + 1}: ${item.message}`).join("; ")}`); } const streams = streamResults.map((result) => result.value); for (let index = 0; index < streams.length; index += 1) { const stream = streams[index]; const terminal = terminalEvent(stream.events); evidence.streams.push({ index, runId: stream.runId, eventCount: stream.events.length, eventNames: stream.events.map((entry) => entry.event), terminalEvent: terminal ? terminal.event : null, }); await fs.writeFile( path.join(OUT_DIR, `events-${index + 1}-${stream.runId}.json`), JSON.stringify(stream.events, null, 2), "utf8", ); assert(terminal, `run ${index + 1} 必须收到 run.completed、run.failed 或 run.aborted`); if (index === CANCEL_INDEX) { assert.equal(terminal.event, "run.aborted", "被 abort 的 run 必须以 run.aborted 结束"); } } assert.equal(new Set(evidence.sessions.map((item) => item.sessionId)).size, RUN_COUNT, "sessionId 必须互相隔离"); assert.equal(new Set(evidence.runs.map((item) => item.runId)).size, RUN_COUNT, "runId 必须互相隔离"); assert(evidence.abort.ok === true, "abort API 必须返回 ok=true"); evidence.ok = true; await fs.writeFile(path.join(OUT_DIR, "result.json"), JSON.stringify(evidence, null, 2), "utf8"); console.log(JSON.stringify(evidence, null, 2)); } finally { await cleanupDocuments(context.request, createdIds).catch(() => undefined); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch(async (error) => { await fs.mkdir(OUT_DIR, { recursive: true }).catch(() => undefined); await fs .writeFile( path.join(OUT_DIR, "error.json"), JSON.stringify( { ok: false, message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : null, }, null, 2, ), "utf8", ) .catch(() => undefined); console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); });