303 lines
10 KiB
JavaScript
303 lines
10 KiB
JavaScript
#!/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);
|
|
});
|