Files
mnote/scripts/task561-page-ai-session-dashboard-smoke.js
T
lix-2026 4a7efd4a30 feat: Page AI Reasonix desktop session alignment, settings IA cleanup, knowledge RAG hardening
- 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
2026-06-13 22:20:01 +08:00

200 lines
15 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_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-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
const settingTabs = page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]');
const settingTabTexts = await settingTabs.evaluateAll((nodes) => nodes.map((node) => node.textContent.trim()).filter(Boolean));
assert.deepEqual(settingTabTexts, ['MNote', 'Reasonix', 'Hermes'], `设置页 tabs 应只包含 MNote / Reasonix / Hermes: ${JSON.stringify(settingTabTexts)}`);
assert(!settingTabTexts.includes('Common') && !settingTabTexts.includes('Chat-only') && !settingTabTexts.includes('高级') && !settingTabTexts.includes('Runtime'), `设置页不应显示旧一级 tab: ${JSON.stringify(settingTabTexts)}`);
const mnotePanelText = await page.locator('[data-page-ai-panel="agent"]').textContent({ timeout: TIMEOUT_MS });
assert(String(mnotePanelText || '').includes('授权区域'), 'MNote tab 应显示授权区域');
assert(String(mnotePanelText || '').includes('默认上下文'), 'MNote tab 应显示默认上下文');
assert(String(mnotePanelText || '').includes('MNote 工具'), 'MNote tab 应显示 MNote 工具');
assert(String(mnotePanelText || '').includes('会话与审计'), 'MNote tab 应显示会话与审计');
await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: TIMEOUT_MS });
const reasonixPanelText = await page.locator('[data-page-ai-panel="reasonix-settings"]').textContent({ timeout: TIMEOUT_MS });
assert(String(reasonixPanelText || '').includes('ACP runtime'), 'Reasonix tab 应显示 ACP runtime');
assert(!/MNOTE_WEB_HERMES_UPSTREAM_URL|API key|Hermes 设置入口/.test(String(reasonixPanelText || '')), `Reasonix tab 不应显示 Hermes gateway/API key 错误: ${reasonixPanelText}`);
await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="hermes-settings"]').click({ timeout: TIMEOUT_MS });
const hermesPanelText = await page.locator('[data-page-ai-panel="hermes-settings"]').textContent({ timeout: TIMEOUT_MS });
assert(String(hermesPanelText || '').includes('Hermes profile'), 'Hermes tab 应显示 profile');
assert(String(hermesPanelText || '').includes('Hermes gateway'), 'Hermes tab 应显示 gateway');
assert(!String(hermesPanelText || '').includes('native-live queue'), 'Hermes tab 不应显示 Reasonix native-live queue 当前状态');
const settingsScreenshotPath = path.join(OUT_DIR, "task561-settings-ia.png");
await page.screenshot({ path: settingsScreenshotPath, fullPage: true });
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 });
const statusFilter = page.locator('[data-page-ai-session-status-filter]');
await statusFilter.waitFor({ state: "visible", timeout: TIMEOUT_MS });
const statusValues = await statusFilter.locator('option').evaluateAll((options) => options.map((option) => option.value));
assert(statusValues.includes('completed'), `status filter 应包含 completed: ${JSON.stringify(statusValues)}`);
assert.equal(await row.getAttribute('data-page-ai-session-status'), 'completed');
await statusFilter.selectOption('completed', { timeout: TIMEOUT_MS });
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 });
const beforeDeleteScreenshotPath = path.join(OUT_DIR, "task561-session-dashboard-before-delete.png");
await page.screenshot({ path: beforeDeleteScreenshotPath, fullPage: true });
const exportApiResponse = await context.request.fetch(`${baseUrl}/api/page-ai/sessions/${encodeURIComponent(sessionId)}/export?workspaceId=${encodeURIComponent(workspaceId)}&documentId=${encodeURIComponent(documentId)}&limit=200`, { timeout: TIMEOUT_MS });
const exportApiText = await exportApiResponse.text();
assert(exportApiResponse.ok(), `/api/page-ai/sessions export 失败: ${exportApiResponse.status()} ${exportApiText.slice(0, 500)}`);
const exportApiPayload = JSON.parse(exportApiText);
assert.deepEqual(exportApiPayload.export.formats, ['json', 'markdown', 'jsonl']);
assert(String(exportApiPayload.export.markdown || '').includes(sessionId), 'markdown export 应包含 sessionId');
assert(String(exportApiPayload.export.jsonl || '').includes('"eventType"'), 'jsonl export 应包含事件行');
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, settingsScreenshotPath, beforeDeleteScreenshotPath, 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); });