#!/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, seedAiRuntime, getAiRuntimeRun, } = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed")); 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 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()); } 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 = `file://${root}`; const relativePath = `Task561-${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), `# Task561 Dashboard\n\n${suffix}\n`, "utf8"); // 通过 API helper 写入 workspace/用户/授权 + run/events,不再直写 SQLite await setupWorkspaceAccess(context.request, baseUrl, { actorId, email: `${actorId}@example.com`, username: actorId, displayName: actorId, role: "user", workspaceId, workspaceName: "Task561 Session Dashboard", rootUri, rootPath: root, sourceKind: "local_folder", permission: "write", capabilities: ["ai"], grantSource: "smoke", grantCreatedBy: actorId, timeoutMs: TIMEOUT_MS, }); await seedAiRuntime(context.request, baseUrl, { id: `airun_${runId}`, userId: actorId, workspaceId, documentId, sessionId, runId, title: `Task561 Seed ${suffix}`, profile: "reasonix", acpRuntime: "reasonix", traceId: `trace_${suffix}`, status: "completed", runtimeJson: { status: "completed", acpSessionId: `acp_${suffix}` }, payloadJson: { message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: "reasonix", acpRuntime: "reasonix" }, events: [ { id: `aievt_${runId}_1`, eventType: "message.delta", payloadJson: { delta: `Task561 assistant answer ${suffix}` } }, { id: `aievt_${runId}_2`, eventType: "run.completed", payloadJson: { stopReason: "EndTurn" } }, ], timeoutMs: TIMEOUT_MS, }); 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 }); // 通过 API helper 验证软删除,不再直读 SQLite const deletedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId }); assert(deletedRun, `run ${runId} 应仍 persist(软删除)`); assert(deletedRun.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); });