Files
mnote/scripts/task560-page-ai-hermes-load-replay-smoke.js
T

285 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 filtersource === '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);
});