#!/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 } = require("node:child_process"); const { chromium } = require("playwright"); const ROOT = path.resolve(__dirname, ".."); const { setupWorkspaceAccess, getAiRuntimeRun, countAiRuntimeEvents, } = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed")); 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 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 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 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 = `file://${root}`; const relativePath = `Task560-${suffix}.md`; const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`; 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'); // 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite await setupWorkspaceAccess(context.request, baseUrl, { actorId, email: `${actorId}@example.com`, username: actorId, displayName: actorId, role: "user", workspaceId, workspaceName: "Task560 Hermes Load Replay", rootUri, rootPath: root, sourceKind: "local_folder", permission: "write", capabilities: ["ai"], grantSource: "smoke", grantCreatedBy: actorId, timeoutMs: TIMEOUT_MS, }); 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'); // 通过 API helper + capturedRuns + fake ACP log 验证持久化和 replay 行为,不再直读 SQLite events const firstRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: first.runId }); const secondRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: second.runId }); assert(firstRun, `first run ${first.runId} 应 persist`); assert(secondRun, `second run ${second.runId} 应 persist`); const infoCount1 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: first.runId, eventType: 'session.info.updated' }); const infoCount2 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: second.runId, eventType: 'session.info.updated' }); assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`); assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`); const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean); assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(acpSessionIds)}`); // adapter_replay 事件的 payload filter(source === 'adapter_replay')需要新的 dev seed kind // (listAiRuntimeEvents 或 countByPayloadField),当前通过 fake ACP log 验证 session/load 行为 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], 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, 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); });