#!/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_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-")); 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 { appendFileSync } from 'node:fs'; import { stdin as input, stdout as output } from 'node:process'; const rl = readline.createInterface({ input, output, terminal: false }); const LOG = process.env.TASK562_FAKE_LOG || ''; let nextSessionId = 1; let pendingPrompt = null; let permissionRequestId = 7; let permissionId = 'task562_perm_1'; let marker = 'TASK562_DONE'; function log(value) { if (LOG) appendFileSync(LOG, JSON.stringify(value) + '\\n', 'utf8'); } 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'); } rl.on('line', (line) => { const msg = JSON.parse(line); log({ dir: 'in', method: msg.method || '', id: msg.id || null, hasResult: Boolean(msg.result), hasError: Boolean(msg.error) }); if (msg.method === 'initialize') { send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task562-fake-reasonix', version: '1.0' }, authMethods: [] } }); return; } if (msg.method === 'session/new') { send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task562_reasonix_session_' + nextSessionId++ } }); return; } if (msg.method === 'session/prompt') { const sessionId = msg.params?.sessionId || ''; const prompt = promptText(msg.params?.prompt); marker = (String(prompt).match(/TASK562_[A-Z0-9_]+/) || ['TASK562_DONE'])[0]; pendingPrompt = { id: msg.id, sessionId, prompt }; send({ jsonrpc: '2.0', id: permissionRequestId, method: 'session/request_permission', params: { permissionId, toolName: 'mcp__demo__write_file', toolCall: { toolCallId: 'gate-call_1', title: 'bash', kind: 'execute', rawInput: { command: 'echo reasonix permission' } }, options: [ { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' } ] } }); log({ dir: 'out', method: 'session/request_permission', id: permissionRequestId, permissionId }); return; } if (msg.id === permissionRequestId && msg.result) { log({ dir: 'permission-resolved', result: msg.result }); if (!pendingPrompt) return; send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: pendingPrompt.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } }); send({ jsonrpc: '2.0', id: pendingPrompt.id, result: { stopReason: 'end_turn' } }); pendingPrompt = null; } }); `, "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 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 }); await page.waitForFunction( () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix", null, { timeout: TIMEOUT_MS }, ); } async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs"); const fakeLog = path.join(OUT_DIR, "fake-reasonix-acp.jsonl"); writeFakeReasonixAcp(fakeScript); const port = await pickPort(); const baseUrl = `http://127.0.0.1:${port}`; const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], env: { TASK562_FAKE_LOG: fakeLog }, 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-task562-reasonix-approval-")); 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}:task562-${suffix.toLowerCase()}`; const rootUri = fileUrl(root); const relativePath = `Task562-${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), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8"); 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(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 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)}, 'Task562 Reasonix Approval', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1); `); sqliteExec(` 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_task562_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1); `); 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); await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS }); await page.locator('[data-page-ai-tab="reasonix-settings"]').first().click({ timeout: TIMEOUT_MS }); await page.waitForFunction( () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'reasonix-settings', null, { timeout: TIMEOUT_MS }, ); const modelSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.model_id"]'); const approvalSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.approval_mode"]'); const planSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.plan_mode"]'); await modelSelect.selectOption("mimo-pro", { timeout: TIMEOUT_MS }); await approvalSelect.selectOption("ask", { timeout: TIMEOUT_MS }); await planSelect.selectOption("auto", { timeout: TIMEOUT_MS }); await page.waitForFunction( () => { const text = document.querySelector('[data-page-ai-model-status]')?.textContent || ''; return text.includes('Reasonix') && text.includes('mimo-pro') && text.includes('审批:询问') && text.includes('计划:自动'); }, null, { timeout: TIMEOUT_MS }, ); await page.evaluate(() => { const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); const buttons = Array.from(drawer ? drawer.querySelectorAll('[data-page-ai-tab="chat"]') : []); const button = buttons.find((node) => node instanceof HTMLElement && node.offsetParent !== null); if (button) button.click(); }); await page.waitForFunction( () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'chat', null, { timeout: TIMEOUT_MS }, ); await page.waitForSelector('[data-page-ai-reasonix-quick-controls]:not([hidden])', { timeout: TIMEOUT_MS }); const quickControlsText = await page.locator('[data-page-ai-reasonix-quick-controls]').textContent({ timeout: TIMEOUT_MS }); assert(String(quickControlsText || '').includes('模型'), `聊天输入区应显示模型选择: ${quickControlsText}`); assert(String(quickControlsText || '').includes('审批'), `聊天输入区应显示审批选择: ${quickControlsText}`); assert(String(quickControlsText || '').includes('Plan'), `聊天输入区应显示 Plan 选择: ${quickControlsText}`); assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.model_id"]').inputValue({ timeout: TIMEOUT_MS }), 'mimo-pro', '模型 quick control 应同步设置值'); assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.approval_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'ask', '审批 quick control 应同步设置值'); assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.plan_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'auto', 'Plan quick control 应同步设置值'); const marker = `TASK562_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.querySelector('[data-page-ai-permission-dialog]') && !document.querySelector('[data-page-ai-permission-dialog]')?.hidden, null, { timeout: TIMEOUT_MS }, ); const permissionText = await page.locator('[data-page-ai-permission-dialog]').textContent({ timeout: TIMEOUT_MS }); assert(String(permissionText || '').includes('bash') && String(permissionText || '').includes('echo reasonix permission'), `权限弹窗应展示 tool 和命令: ${permissionText}`); const resolveRequestPromise = page.waitForRequest( (request) => request.url().includes('/resolve-permission') && request.method() === 'POST', { timeout: 5000 }, ).catch(() => null); const resolveResponsePromise = page.waitForResponse( (response) => response.url().includes('/resolve-permission'), { timeout: 5000 }, ).catch(() => null); await page.evaluate(() => { const button = document.querySelector('[data-page-ai-permission-dialog] [data-page-ai-permission-action="allow"]'); if (button instanceof HTMLElement) button.click(); }); const resolveRequest = await resolveRequestPromise; assert(resolveRequest, '点击允许后应发出 resolve-permission 请求'); const resolveResponse = await resolveResponsePromise; assert(resolveResponse && resolveResponse.ok(), `resolve-permission 应成功,实际 status=${resolveResponse && resolveResponse.status()} url=${resolveRequest.url()} body=${resolveRequest.postData() || ''}`); 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, `AI 回复不符合预期: ${JSON.stringify(text && text.slice(0, 800))}`); 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('mimo-pro'), `runtime strip 应显示已选 model: ${runtimeStrip}`); assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${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 screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, 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_TASK562_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true }); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });