2026-06-13 22:20:01 +08:00
#!/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" );
2026-07-03 13:24:20 +08:00
const { spawn } = require ( "node:child_process" );
2026-06-13 22:20:01 +08:00
const { chromium } = require ( "playwright" );
const ROOT = path . resolve ( __dirname , ".." );
2026-07-03 13:24:20 +08:00
const {
setupWorkspaceAccess ,
getAiRuntimeRun ,
} = require ( path . join ( ROOT , "scripts" , "lib" , "control-plane-dev-seed" ));
2026-06-13 22:20:01 +08:00
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 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 () } ` ;
2026-07-03 13:24:20 +08:00
const rootUri = `file:// ${ root } ` ;
2026-06-13 22:20:01 +08:00
const relativePath = `Task562- ${ suffix } .md` ;
2026-07-03 13:24:20 +08:00
const documentId = `local-md: ${ relativePath . replaceAll ( "/" , "~2F" ) } ` ;
2026-06-13 22:20:01 +08:00
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" );
2026-07-03 13:24:20 +08:00
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess ( context . request , baseUrl , {
actorId ,
email : ` ${ actorId } @example.com` ,
username : actorId ,
displayName : actorId ,
role : "user" ,
workspaceId ,
workspaceName : "Task562 Reasonix Approval" ,
rootUri ,
rootPath : root ,
sourceKind : "local_folder" ,
permission : "write" ,
capabilities : [ "ai" ],
grantSource : "smoke" ,
grantCreatedBy : actorId ,
timeoutMs : TIMEOUT_MS ,
});
2026-06-13 22:20:01 +08:00
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 } ` );
2026-07-03 13:24:20 +08:00
// 通过 API helper 读取 run 持久化状态,不再直读 SQLite
const persistedRun = await getAiRuntimeRun ( context . request , baseUrl , { userId : actorId , runId });
assert ( persistedRun , `run ${ runId } 应 persist` );
assert . equal ( persistedRun . status , 'completed' , 'API run status 应为 completed' );
2026-06-13 22:20:01 +08:00
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 ); });