feat(page-ai): align ACP session runtime

This commit is contained in:
lix-2026
2026-06-10 07:09:04 +08:00
parent f698aad964
commit 2cfeb97763
13 changed files with 3377 additions and 48 deletions
@@ -0,0 +1,218 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-"));
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function fileUrl(localPath) {
return `file://${localPath}`;
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function sqliteJson(sql) {
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
return out.trim() ? JSON.parse(out) : [];
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task558 Reasonix Live', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function signIn(context) {
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
},
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
const whoami = await context.request.fetch(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS });
const whoamiText = await whoami.text();
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
return JSON.parse(whoamiText);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function newestAssistantText(page, beforeCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'));
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
return node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
}, beforeCount);
}
function normalize(text) {
return String(text || "").replace(/\s+/g, "").trim();
}
async function sendPrompt(page, prompt, expectedCompact) {
const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: Math.max(UI_TIMEOUT_MS, 120_000) },
);
const status = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "");
assert.equal(status, "completed", `Page AI run 应完成,实际 status=${status}`);
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: UI_TIMEOUT_MS });
const text = await newestAssistantText(page, before);
assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`);
const runId = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-id") || "");
assert(runId, "缺少 data-mnote-page-ai-run-id");
return { runId, text };
}
function sessionInfoForRuns(runIds) {
const quoted = runIds.map(sqlQuote).join(",");
return sqliteJson(`
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
FROM ai_runtime_events
WHERE run_id IN (${quoted}) AND event_type = 'session.info.updated'
ORDER BY created_at ASC, id ASC;
`).map((row) => ({
runId: row.runId,
eventType: row.eventType,
payload: JSON.parse(row.payloadJson || "{}"),
}));
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-"));
const rootUri = fileUrl(root);
const relativePath = `Task558-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const capturedRuns = [];
page.on("request", (request) => {
if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return;
try {
capturedRuns.push(JSON.parse(request.postData() || "{}"));
} catch {
capturedRuns.push({ raw: request.postData() || "" });
}
});
try {
const viewer = await signIn(context);
const actorId = viewer.userId || ACTOR;
const workspaceId = `local-ws:${actorId}:task558-${suffix}`;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8");
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task558_${suffix}` });
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("workspaceId", workspaceId);
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await selectReasonix(page);
const firstMarker = `TASK558_FIRST_${suffix}`.toUpperCase();
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
const first = await sendPrompt(
page,
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说“可以”,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
firstMarker,
);
const second = await sendPrompt(page, "可以", secondMarker);
assert.equal(capturedRuns.length, 2, `应捕获两次 Page AI run,实际 ${capturedRuns.length}`);
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
const infos = sessionInfoForRuns([first.runId, second.runId]);
assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`);
const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(infos)}`);
assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id");
const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = {
ok: true,
baseUrl: BASE_URL,
outDir: OUT_DIR,
root,
rootUri,
workspaceId,
documentId,
first,
second,
acpSessionId: acpSessionIds[0],
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
sessionInfo: infos,
screenshotPath,
};
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(OUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), capturedRuns }, null, 2)}\n`, "utf8");
throw error;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
if (process.env.MNOTE_KEEP_TASK558_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,187 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_TERMINAL_STATUS_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task559-terminal-status-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => { request.destroy(); retry(); });
};
const retry = () => {
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
setTimeout(tick, 250);
};
tick();
});
}
function writeFakeReasonixAcp(scriptPath) {
fs.writeFileSync(scriptPath, `
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
let nextId = 1;
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
function markerFrom(text) { return (String(text || '').match(/TASK559_[A-Z0-9_]+/) || ['TASK559_FALLBACK'])[0]; }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (!msg.id) return;
if (msg.method === 'initialize') {
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task559-fake-reasonix', version: '1.0' }, authMethods: [] } });
} else if (msg.method === 'session/new') {
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task559_reasonix_session_' + nextId++ } });
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || '';
const marker = markerFrom(promptText(msg.params?.prompt));
for (let i = 0; i < 600; i += 1) {
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'thought_chunk', content: { type: 'text', text: 'thought-' + i + '\\n' } } } });
}
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } });
}
});
`, "utf8");
}
async function signIn(context, baseUrl) {
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json" },
data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } },
timeout: TIMEOUT_MS,
});
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${await response.text()}`);
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
return JSON.parse(await whoami.text());
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task559 Terminal Status', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS });
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs");
writeFakeReasonixAcp(fakeScript);
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], title: "Reasonix Fake" }]);
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(ROOT, "rust"),
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" },
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task559-terminal-status-"));
let browser;
try {
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const viewer = await signIn(context, baseUrl);
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36).toUpperCase();
const workspaceId = `local-ws:${actorId}:task559-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const relativePath = `Task559-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task559 Terminal Status\n\n${suffix}\n`, "utf8");
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task559_${suffix}` });
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("workspaceId", workspaceId);
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
await selectReasonix(page);
const marker = `TASK559_DONE_${suffix}`;
await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS });
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
assert(runId, "缺少 runId");
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, "completed", "SQLite run status 应为 completed");
const terminalEvents = sqliteJson(`SELECT COUNT(*) AS count FROM ai_runtime_events WHERE run_id=${sqlQuote(runId)} AND event_type='run.completed';`);
assert(Number(terminalEvents[0] && terminalEvents[0].count) >= 1, "应持久化 run.completed");
const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
await context.close().catch(() => undefined);
} catch (error) {
fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8");
throw error;
} finally {
if (browser) await browser.close().catch(() => undefined);
server.kill("SIGTERM");
await new Promise((resolve) => server.once("exit", resolve));
if (process.env.MNOTE_KEEP_TASK559_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });
@@ -0,0 +1,304 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_HERMES_LOAD_REPLAY_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task560-hermes-load-replay-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function sqliteJson(sql) {
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
return out.trim() ? JSON.parse(out) : [];
}
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => {
request.destroy();
retry();
});
};
const retry = () => {
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
setTimeout(tick, 250);
};
tick();
});
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task560 Hermes Load Replay', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function writeFakeHermesAcp(scriptPath) {
fs.writeFileSync(scriptPath, `
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
import fs from 'node:fs';
const rl = readline.createInterface({ input, output, terminal: false });
const replay = process.env.MNOTE_TASK560_HERMES_REPLAY === '1';
const logPath = process.env.MNOTE_TASK560_FAKE_LOG || '';
let nextId = 1;
function log(entry) { if (logPath) fs.appendFileSync(logPath, JSON.stringify({ ...entry, at: Date.now() }) + '\\n'); }
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
function markerFrom(text) { return (String(text || '').match(/TASK560_[A-Z0-9_]+/) || ['TASK560_FALLBACK'])[0]; }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (!msg.id) return;
if (msg.method === 'initialize') {
log({ method: msg.method });
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: true }, agentInfo: { name: 'task560-fake-hermes', version: '1.0' }, authMethods: [] } });
} else if (msg.method === 'session/new') {
const sessionId = 'task560_hermes_session_' + nextId++;
log({ method: msg.method, sessionId });
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } });
} else if (msg.method === 'session/load') {
const sessionId = msg.params?.sessionId || 'missing_session';
log({ method: msg.method, sessionId, replay });
if (replay) {
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'TASK560_REPLAY_SHOULD_NOT_APPEAR' } } } });
}
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } });
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || '';
const marker = markerFrom(promptText(msg.params?.prompt));
log({ method: msg.method, sessionId, marker });
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } });
}
});
`, "utf8");
}
async function signIn(context, baseUrl) {
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json" },
data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } },
timeout: TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
const whoamiText = await whoami.text();
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
return JSON.parse(whoamiText);
}
async function selectHermes(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="hermes"]').first().click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-mnote-acp-runtime') === 'hermes',
null,
{ timeout: TIMEOUT_MS },
);
}
function normalize(text) {
return String(text || "").replace(/\s+/g, "").trim();
}
async function newestAssistantText(page, beforeCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'));
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
return node?.querySelector('.wolai-page-ai-message-text')?.textContent || '';
}, beforeCount);
}
async function sendPrompt(page, prompt, expectedCompact) {
const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
await page.locator('[data-page-ai-input]').fill(prompt, { timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => ['completed', 'failed', 'aborted'].includes(document.documentElement.getAttribute('data-mnote-page-ai-run-status') || ''),
null,
{ timeout: Math.max(TIMEOUT_MS, 120_000) },
);
const status = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') || '');
assert.equal(status, 'completed', `Page AI run 应完成,实际 status=${status}`);
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
const text = await newestAssistantText(page, before);
assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`);
assert(!normalize(text).includes('TASK560_REPLAY_SHOULD_NOT_APPEAR'), 'Hermes replay 不应出现在当前 assistant 正文');
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
assert(runId, '缺少 data-mnote-page-ai-run-id');
return { runId, text };
}
function eventsForRuns(runIds) {
const quoted = runIds.map(sqlQuote).join(',');
return sqliteJson(`
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
FROM ai_runtime_events
WHERE run_id IN (${quoted})
ORDER BY created_at ASC, id ASC;
`).map((row) => ({
runId: row.runId,
eventType: row.eventType,
payload: JSON.parse(row.payloadJson || '{}'),
}));
}
function readFakeLog(logPath) {
if (!fs.existsSync(logPath)) return [];
return fs.readFileSync(logPath, 'utf8').trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line));
}
async function runVariant({ replay }) {
const variant = replay ? 'with-replay' : 'no-replay';
const variantDir = path.join(OUT_DIR, variant);
fs.mkdirSync(variantDir, { recursive: true });
const fakeScript = path.join(variantDir, 'fake-hermes-acp.mjs');
const fakeLog = path.join(variantDir, 'fake-hermes-acp.jsonl');
writeFakeHermesAcp(fakeScript);
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const runtimes = JSON.stringify([{ name: 'hermes', bin: 'node', args: [fakeScript], env: { MNOTE_TASK560_HERMES_REPLAY: replay ? '1' : '0', MNOTE_TASK560_FAKE_LOG: fakeLog }, title: 'Hermes Fake' }]);
const server = spawn('cargo', ['run', '-q', '-p', 'mnote-web', '--bin', 'mnote-web'], {
cwd: path.join(ROOT, 'rust'),
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: 'hermes' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
server.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
server.stdout.on('data', () => {});
let browser;
const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-task560-hermes-${variant}-`));
try {
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
browser = await chromium.launch({ headless: process.env.HEADFUL !== '1', executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const capturedRuns = [];
page.on('request', (request) => {
if (!request.url().includes('/api/hermes/client/runs') || request.method() !== 'POST') return;
try { capturedRuns.push(JSON.parse(request.postData() || '{}')); } catch { capturedRuns.push({ raw: request.postData() || '' }); }
});
const viewer = await signIn(context, baseUrl);
const actorId = viewer.userId || ACTOR;
const suffix = `${Date.now().toString(36)}_${variant.replace('-', '_')}`.toUpperCase();
const workspaceId = `local-ws:${actorId}:task560-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const relativePath = `Task560-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
fs.mkdirSync(path.join(root, '.mnote'), { recursive: true });
fs.writeFileSync(path.join(root, '.mnote', 'workspace.json'), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, 'utf8');
fs.writeFileSync(path.join(root, relativePath), `# Task560 Hermes Load Replay\n\n${suffix}\n`, 'utf8');
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task560_${suffix}` });
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('workspaceId', workspaceId);
await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
await page.getByTestId('wolai-floating-ai').click({ timeout: TIMEOUT_MS });
await selectHermes(page);
const firstMarker = `TASK560_FIRST_${suffix}`;
const secondMarker = `TASK560_SECOND_${suffix}`;
const first = await sendPrompt(page, `只回复 ${firstMarker},不要解释。`, firstMarker);
const second = await sendPrompt(page, `只回复 ${secondMarker},不要解释。`, secondMarker);
assert.equal(capturedRuns.length, 2, `应捕获两次 Hermes Page AI run,实际 ${capturedRuns.length}`);
assert(capturedRuns.every((body) => body.acpRuntime === 'hermes'), '两次 run 都应走 Hermes ACP');
assert(capturedRuns[1].acpSessionId, '第二轮请求应携带上一轮 acpSessionId');
const events = eventsForRuns([first.runId, second.runId]);
const sessionInfos = events.filter((event) => event.eventType === 'session.info.updated');
assert.equal(sessionInfos.length, 2, `应有两条 session.info.updated,实际 ${sessionInfos.length}`);
const acpSessionIds = sessionInfos.map((event) => String(event.payload.acpSessionId || '')).filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(sessionInfos)}`);
const replayEvents = events.filter((event) => event.payload && event.payload.source === 'adapter_replay');
assert.equal(replayEvents.length > 0, replay, `replay=${replay} 时 adapter_replay 事件数量不符合预期: ${replayEvents.length}`);
const fakeLogRows = readFakeLog(fakeLog);
assert(fakeLogRows.some((row) => row.method === 'session/new'), '第一轮应调用 session/new');
assert(fakeLogRows.some((row) => row.method === 'session/load' && row.sessionId === acpSessionIds[0]), '第二轮应先 session/load stored acpSessionId');
const screenshotPath = path.join(variantDir, 'task560-hermes-load-replay.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], replayEvents, fakeLogRows, capturedRuns, screenshotPath };
fs.writeFileSync(path.join(variantDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await context.close().catch(() => undefined);
return result;
} catch (error) {
fs.writeFileSync(path.join(variantDir, 'failure.json'), `${JSON.stringify({ ok: false, variant, error: String(error && error.stack || error), stderr, fakeLog: readFakeLog(fakeLog) }, null, 2)}\n`, 'utf8');
throw error;
} finally {
if (browser) await browser.close().catch(() => undefined);
server.kill('SIGTERM');
await new Promise((resolve) => server.once('exit', resolve));
if (process.env.MNOTE_KEEP_TASK560_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true });
}
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const results = [];
results.push(await runVariant({ replay: false }));
results.push(await runVariant({ replay: true }));
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, replayEvents: result.replayEvents.length, screenshotPath: result.screenshotPath })) };
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.log(JSON.stringify(summary, null, 2));
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,162 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_SESSION_DASHBOARD_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task561-session-dashboard-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => { request.destroy(); retry(); });
};
const retry = () => {
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
setTimeout(tick, 250);
};
tick();
});
}
async function signIn(context, baseUrl) {
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json" },
data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } },
timeout: TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
return JSON.parse(await whoami.text());
}
function seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task561 Session Dashboard', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(`grant_task561_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO ai_runtime_runs (id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision)
VALUES (${sqlQuote(`airun_${runId}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, ${sqlQuote(`Task561 Seed ${suffix}`)}, 'reasonix', 'reasonix', ${sqlQuote(`trace_${suffix}`)}, 'completed', ${sqlQuote(JSON.stringify({ status: 'completed', acpSessionId: `acp_${suffix}` }))}, ${sqlQuote(JSON.stringify({ message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: 'reasonix', acpRuntime: 'reasonix' }))}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES (${sqlQuote(`aievt_${runId}_1`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', ${sqlQuote(JSON.stringify({ delta: `Task561 assistant answer ${suffix}` }))}, ${sqlQuote(now)});
INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES (${sqlQuote(`aievt_${runId}_2`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'run.completed', ${sqlQuote(JSON.stringify({ stopReason: 'EndTurn' }))}, ${sqlQuote(now)});
`);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(ROOT, "rust"),
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}` },
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task561-session-dashboard-"));
let browser;
try {
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const viewer = await signIn(context, baseUrl);
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36);
const workspaceId = `local-ws:${actorId}:task561-${suffix}`;
const sessionId = `mnote_task561_${suffix}`;
const runId = `run_task561_${suffix}`;
const rootUri = fileUrl(root);
const relativePath = `Task561-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task561 Dashboard\n\n${suffix}\n`, "utf8");
seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix });
const page = await context.newPage();
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("workspaceId", workspaceId);
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-action="history"]').first().click({ timeout: TIMEOUT_MS });
const row = page.locator(`[data-page-ai-session-row="${sessionId}"]`).first();
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-session-search]').fill(`dashboard seed ${suffix}`, { timeout: TIMEOUT_MS });
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.locator(`[data-page-ai-session-export="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
await page.waitForFunction((id) => document.documentElement.getAttribute('data-mnote-page-ai-session-exported') === id, sessionId, { timeout: TIMEOUT_MS });
await page.evaluate(() => { window.prompt = () => 'Task561 Renamed'; });
await page.locator(`[data-page-ai-session-rename="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
await page.waitForFunction((id) => {
const row = document.querySelector(`[data-page-ai-session-row="${id}"]`);
return row && row.textContent.includes('Task561 Renamed');
}, sessionId, { timeout: TIMEOUT_MS });
await page.evaluate(() => { window.confirm = () => true; });
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
await page.waitForFunction((id) => !document.querySelector(`[data-page-ai-session-row="${id}"]`), sessionId, { timeout: TIMEOUT_MS });
const deletedRows = sqliteJson(`SELECT deleted_at AS deletedAt FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert(deletedRows[0] && deletedRows[0].deletedAt, "delete 应软删除 SQLite session runs");
const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, screenshotPath };
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
await context.close().catch(() => undefined);
} catch (error) {
fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8");
throw error;
} finally {
if (browser) await browser.close().catch(() => undefined);
server.kill("SIGTERM");
await new Promise((resolve) => server.once("exit", resolve));
if (process.env.MNOTE_KEEP_TASK561_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });