- ACP client/session manager: Reasonix desktop live session context - Hermes tools: knowledge_rag tool manifest and skill updates - Browser runtime: sidebar page AI permission/profile/render/session/tree modules - Routes: hermes_client, hermes_tools, knowledge_rag, web_shell - Scripts: reasonix ACP wrapper, LightRAG MCP, smoke tasks 159/558/559/561/562 - Skills: mnote-knowledge-rag and mnote-lightrag-bridge SKILL.md updates
218 lines
14 KiB
JavaScript
218 lines
14 KiB
JavaScript
#!/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 runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
|
|
assert(String(runtimeStrip || '').includes('reasonix'), `runtime strip 应显示 reasonix: ${runtimeStrip}`);
|
|
assert(!String(runtimeStrip || '').includes('输出中'), `完成后 runtime strip 不应残留输出中: ${runtimeStrip}`);
|
|
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 });
|
|
await page.locator('[data-page-ai-runtime-strip]').click({ timeout: TIMEOUT_MS });
|
|
await page.waitForFunction(() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'status', null, { timeout: TIMEOUT_MS });
|
|
await page.waitForFunction(() => {
|
|
const detail = document.querySelector('[data-page-ai-runtime-detail]')?.textContent || '';
|
|
const jobs = document.querySelector('[data-page-ai-jobs-list]')?.textContent || '';
|
|
const logs = document.querySelector('[data-page-ai-logs-list]')?.textContent || '';
|
|
const usage = document.querySelector('[data-page-ai-usage-detail]')?.textContent || '';
|
|
return detail.includes('runtime') && detail.includes('MCP/tools') && jobs.length > 0 && logs.length > 0 && usage.length > 0;
|
|
}, null, { timeout: TIMEOUT_MS });
|
|
const statusText = await page.locator('[data-page-ai-panel="status"]').textContent({ timeout: TIMEOUT_MS });
|
|
assert(String(statusText || '').includes('Current runtime'), '状态页应显示 Current runtime');
|
|
assert(String(statusText || '').includes('Queue'), '状态页应显示 Queue');
|
|
assert(String(statusText || '').includes('Jobs'), '状态页应显示 Jobs');
|
|
assert(String(statusText || '').includes('Logs tail'), '状态页应显示 Logs tail');
|
|
assert(String(statusText || '').includes('Usage'), '状态页应显示 Usage');
|
|
const runtimePanelScreenshotPath = path.join(OUT_DIR, "task559-runtime-panel.png");
|
|
await page.screenshot({ path: runtimePanelScreenshotPath, fullPage: true });
|
|
await page.setViewportSize({ width: 452, height: 900 });
|
|
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
|
const mobileTabs = await page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]').evaluateAll((nodes) => nodes.map((node) => {
|
|
const rect = node.getBoundingClientRect();
|
|
return { text: node.textContent.trim(), box: { width: rect.width, height: rect.height } };
|
|
}));
|
|
assert.deepEqual(mobileTabs.map((tab) => tab.text), ['MNote', 'Reasonix', 'Hermes'], `移动宽度设置 tabs 应保持三层: ${JSON.stringify(mobileTabs)}`);
|
|
assert(mobileTabs.every((tab) => tab.box.width > 40 && tab.box.height > 24), `移动宽度 tabs 不应挤压不可点: ${JSON.stringify(mobileTabs)}`);
|
|
const mobileSettingsScreenshotPath = path.join(OUT_DIR, "task559-settings-mobile.png");
|
|
await page.screenshot({ path: mobileSettingsScreenshotPath, fullPage: true });
|
|
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath, runtimePanelScreenshotPath, mobileSettingsScreenshotPath };
|
|
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); });
|