2026-07-10 10:54:34 +08:00
#!/usr/bin/env node
"use strict" ;
const assert = require ( "node:assert" );
const fs = require ( "node:fs" );
const os = require ( "node:os" );
const path = require ( "node:path" );
const { chromium } = require ( "playwright" );
const BASE = process . env . MNOTE_UI_BASE_URL || "http://127.0.0.1:3000" ;
const STAMP = Date . now ();
const OUT = process . env . MNOTE_PI_PLAN_MODE_OUT || path . join ( os . tmpdir (), `mnote-pi-plan-mode- ${ STAMP } ` );
const TIMEOUT = Number . parseInt ( process . env . UI_TIMEOUT_MS || "240000" , 10 );
const ACTOR_ID = process . env . MNOTE_E2E_ACTOR_ID || "mnote-e2e" ;
const WORKSPACE_ID = process . env . MNOTE_PI_PLAN_MODE_WORKSPACE_ID || "local-ws:mnote-e2e:my-space" ;
const ROOT_PATH = process . env . MNOTE_PI_PLAN_MODE_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space" ;
const ROOT_URI = process . env . MNOTE_PI_PLAN_MODE_ROOT_URI || `file:// ${ ROOT_PATH } ` ;
const MODEL_PROVIDER = process . env . MNOTE_PI_PLAN_MODE_MODEL_PROVIDER || "omniroute" ;
2026-07-11 20:39:18 +08:00
const MODEL_ID = process . env . MNOTE_PI_PLAN_MODE_MODEL_ID || "gpt-5.4-mini" ;
2026-07-10 10:54:34 +08:00
const PAGE_PATH = `pi-plan-mode- ${ STAMP } .md` ;
const TARGET_PATH = `pi-plan-mode-target- ${ STAMP } .md` ;
const TARGET_ABS = path . join ( ROOT_PATH , TARGET_PATH );
const TARGET_ORIGINAL = `PLAN_MODE_TARGET_ORIGINAL_ ${ STAMP } \n` ;
const CHROMIUM_EXECUTABLE = process . env . PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| ( fs . existsSync ( "/usr/bin/chromium-browser" ) ? "/usr/bin/chromium-browser" : "" )
|| ( fs . existsSync ( "/usr/bin/chromium" ) ? "/usr/bin/chromium" : "" )
|| ( fs . existsSync ( "/usr/bin/google-chrome" ) ? "/usr/bin/google-chrome" : "" );
function mkdirp ( dir ) {
fs . mkdirSync ( dir , { recursive : true });
}
async function quickLogin ( page ) {
await page . goto ( ` ${ BASE } /auth` , { waitUntil : "commit" , timeout : TIMEOUT });
const quickLoginButton = page . getByRole ( "button" , { name : "测试账号快速登录" });
await quickLoginButton . waitFor ({ state : "visible" , timeout : TIMEOUT });
await Promise . all ([
page . waitForURL (( url ) => ! url . toString (). includes ( "/auth" ), { timeout : TIMEOUT }). catch (() => null ),
quickLoginButton . click (),
]);
}
async function requestJson ( page , url , options = {}) {
const response = await page . request . fetch ( ` ${ BASE }${ url } ` , {
... options ,
headers : {
accept : "application/json" ,
"content-type" : "application/json" ,
...( options . headers || {}),
},
timeout : options . timeout || TIMEOUT ,
});
const text = await response . text ();
let body = {};
try {
body = text ? JSON . parse ( text ) : {};
} catch {
body = { raw : text };
}
if ( options . allowStatus ) return { response , body , text };
assert ( response . ok (), ` ${ options . method || "GET" } ${ url } failed: ${ response . status () } ${ text . slice ( 0 , 800 ) } ` );
return body ;
}
function piExtensionConfig ( name , description , source , toolNames , riskLevel , requiredScopes = []) {
return {
name ,
description ,
source ,
toolNames ,
riskLevel ,
requiredScopes ,
enabled : true ,
};
}
function messageTextsFromSessionJsonl ( raw ) {
return raw
. split ( /\n+/ )
. filter ( Boolean )
. flatMap (( line ) => {
try {
const event = JSON . parse ( line );
const content = event && event . message && Array . isArray ( event . message . content )
? event . message . content
: [];
return content
. filter (( item ) => item && item . type === "text" && typeof item . text === "string" )
. map (( item ) => item . text );
} catch {
return [];
}
});
}
function policyForPlanMode () {
return {
defaultModel : ` ${ MODEL_PROVIDER } / ${ MODEL_ID } ` ,
allowedModels : [ ` ${ MODEL_PROVIDER } / ${ MODEL_ID } ` ],
tools : {
"mnote.current_page.read" : "allow" ,
"mnote.selection.read" : "allow" ,
"mnote.allowed_roots.describe" : "allow" ,
"mnote.local_file.read" : "allow" ,
"mnote.local_file.patch" : "ask" ,
"mnote.knowledge_rag.status" : "allow" ,
"mnote.knowledge_rag.query" : "allow" ,
"mnote.knowledge_rag.section_context" : "allow" ,
"mnote.knowledge_rag.open_reference" : "allow" ,
"mnote.reference.open" : "allow" ,
"mnote.tool_receipt.write" : "allow" ,
"mnote.codex_rescue.request" : "ask" ,
},
skills : {},
mcpServers : {},
piExtensions : {
"pi-rust-official-question" : piExtensionConfig (
"Pi Rust Official Question" ,
"Pi Rust 官方索引 question 扩展的本地镜像。" ,
"pi-rust-official:question" ,
[ "question" ],
"low" ,
[ "ui:ask" ],
),
"pi-rust-official-questionnaire" : piExtensionConfig (
"Pi Rust Official Questionnaire" ,
"Pi Rust 官方索引 questionnaire 扩展的本地镜像。" ,
"pi-rust-official:questionnaire" ,
[ "questionnaire" ],
"low" ,
[ "ui:ask" ],
),
},
};
}
async function seedWorkspace ( page ) {
mkdirp ( ROOT_PATH );
fs . writeFileSync ( path . join ( ROOT_PATH , PAGE_PATH ), "# Pi plan mode browser smoke\n" , "utf8" );
fs . writeFileSync ( TARGET_ABS , TARGET_ORIGINAL , "utf8" );
const grantResponse = await page . request . fetch ( ` ${ BASE } /api/admin/access-policy/grants` , {
method : "POST" ,
headers : {
accept : "application/json" ,
"content-type" : "application/json" ,
},
data : {
userId : ACTOR_ID ,
rootUri : ROOT_URI ,
rootPath : ROOT_PATH ,
permission : "write" ,
recursive : true ,
capabilities : [ "ai" ],
},
timeout : TIMEOUT ,
});
const grantText = await grantResponse . text ();
let grantBody = {};
try {
grantBody = grantText ? JSON . parse ( grantText ) : {};
} catch {
grantBody = { raw : grantText };
}
if ( ! grantResponse . ok () && grantBody . code !== "local_access_policy_grant_duplicate" ) {
throw new Error ( `POST /api/admin/access-policy/grants failed: ${ grantResponse . status () } ${ grantText . slice ( 0 , 800 ) } ` );
}
await requestJson ( page , "/api/ai-admin/settings" , {
method : "PUT" ,
data : {
... policyForPlanMode (),
quota : { daily : 200 },
},
});
await requestJson ( page , `/api/ai-settings/effective?workspaceId= ${ encodeURIComponent ( WORKSPACE_ID ) } ` );
}
async function abortExistingSession ( page ) {
const status = await requestJson ( page , "/api/page-ai/pi/status" ). catch (() => null );
const sessionId = status && status . session && status . session . sessionId ;
if ( ! sessionId ) return null ;
await requestJson ( page , "/api/page-ai/pi/abort" , { method : "POST" , data : { sessionId } }). catch (() => null );
return sessionId ;
}
async function startPlanSession ( page ) {
const sessionId = `pi-plan-mode- ${ STAMP } ` ;
const start = await requestJson ( page , "/api/page-ai/pi/start" , {
method : "POST" ,
data : {
sessionId ,
rootUri : ROOT_URI ,
workspaceId : WORKSPACE_ID ,
pagePath : PAGE_PATH ,
pageTitle : "Pi plan mode browser smoke" ,
modelProvider : MODEL_PROVIDER ,
modelId : MODEL_ID ,
thinkingLevel : "off" ,
permissionMode : "plan" ,
},
});
assert . equal ( start . ok , true , "Pi start ok should be true" );
assert . equal ( start . permissionMode , "plan" , "start response should expose plan mode" );
assert . equal ( start . session . runtimePolicySnapshot . permissionMode , "plan" , "runtime policy should persist plan mode" );
return start . session ;
}
function assertPlanPermissionConfig ( session , result ) {
const configPath = path . join ( session . piSessionDir , "config" , "extensions" , "pi-permission-system" , "config.json" );
result . permissionConfigPath = configPath ;
result . checks . permissionSystemConfigAbsent = ! fs . existsSync ( configPath );
result . checks . planRuntimePolicyMode = session . runtimePolicySnapshot . permissionMode ;
result . checks . planQuestionSource = ( session . runtimePolicySnapshot . enabledPiExtensionSources || []). includes ( "pi-rust-official:question" );
result . checks . planQuestionnaireSource = ( session . runtimePolicySnapshot . enabledPiExtensionSources || []). includes ( "pi-rust-official:questionnaire" );
result . checks . noLegacyNpmAskUser = ! ( session . runtimePolicySnapshot . enabledPiExtensionSources || []). includes ( "npm:pi-ask-user" );
result . checks . noExternalPermissionSystem = ! ( session . runtimePolicySnapshot . enabledPiExtensionSources || []). includes ( "npm:@gotgenes/pi-permission-system" );
assert . equal ( result . checks . permissionSystemConfigAbsent , true , "Pi Rust plan smoke should not generate legacy pi-permission-system config" );
assert . equal ( result . checks . planRuntimePolicyMode , "plan" , "plan mode should persist in runtime policy" );
assert . equal ( result . checks . planQuestionSource , true , "plan smoke should use Pi Rust official question extension" );
assert . equal ( result . checks . planQuestionnaireSource , true , "plan smoke should use Pi Rust official questionnaire extension" );
assert . equal ( result . checks . noLegacyNpmAskUser , true , "plan smoke should not use unavailable npm:pi-ask-user" );
assert . equal ( result . checks . noExternalPermissionSystem , true , "plan smoke should not load incompatible pi-permission-system" );
}
async function readLatestSessionJsonl ( sessionDir , timeoutMs = 120000 ) {
const startedAt = Date . now ();
while ( Date . now () - startedAt < timeoutMs ) {
const files = fs . readdirSync ( sessionDir )
. filter (( name ) => name . endsWith ( ".jsonl" ))
. map (( name ) => path . join ( sessionDir , name ))
. sort ();
const sessionFile = files [ files . length - 1 ];
if ( sessionFile ) {
const raw = fs . readFileSync ( sessionFile , "utf8" );
if ( raw . includes ( "当前是 MNote Pi 计划模式" )) {
return { sessionFile , raw };
}
}
await new Promise (( resolve ) => setTimeout ( resolve , 500 ));
}
const files = fs . readdirSync ( sessionDir )
. filter (( name ) => name . endsWith ( ".jsonl" ))
. map (( name ) => path . join ( sessionDir , name ))
. sort ();
const sessionFile = files [ files . length - 1 ];
assert ( sessionFile , `session jsonl not found in ${ sessionDir } ` );
return { sessionFile , raw : fs . readFileSync ( sessionFile , "utf8" ) };
}
async function openPiUi ( page ) {
await page . goto ( ` ${ BASE } /page-ai/pi` , { waitUntil : "commit" , timeout : TIMEOUT });
await page . locator ( '[data-page-ai-pi-lab="drawer"]' ). waitFor ({ state : "visible" , timeout : TIMEOUT });
await page . waitForFunction (() => / 计划模式 / . test ( document . querySelector ( "[data-page-ai-pi-lab-permission-label]" ) ? . textContent || "" ), null , { timeout : TIMEOUT });
}
async function main () {
mkdirp ( OUT );
const browser = await chromium . launch ({
headless : process . env . MNOTE_PI_PLAN_MODE_HEADED === "1" ? false : true ,
executablePath : CHROMIUM_EXECUTABLE || undefined ,
});
const context = await browser . newContext ({ viewport : { width : 1440 , height : 980 } });
const page = await context . newPage ();
const consoleMessages = [];
page . on ( "console" , ( message ) => {
if ([ "error" , "warning" ]. includes ( message . type ())) consoleMessages . push ( ` ${ message . type () } : ${ message . text () } ` );
});
page . on ( "pageerror" , ( error ) => consoleMessages . push ( `pageerror: ${ error . message } ` ));
const result = {
base : BASE ,
outputDir : OUT ,
rootUri : ROOT_URI ,
targetPath : TARGET_PATH ,
targetAbs : TARGET_ABS ,
screenshots : {},
checks : {},
consoleMessages ,
};
try {
await quickLogin ( page );
await seedWorkspace ( page );
await abortExistingSession ( page );
const session = await startPlanSession ( page );
result . session = {
sessionId : session . sessionId ,
runtimeMode : session . runtimeMode ,
runtimePid : session . runtimePid ,
piSessionDir : session . piSessionDir ,
runtimePolicySnapshot : session . runtimePolicySnapshot ,
};
assertPlanPermissionConfig ( session , result );
await openPiUi ( page );
await page . locator ( "[data-page-ai-pi-lab-permission]" ). click ();
await page . locator ( "[data-page-ai-pi-lab-permission-menu]" ). waitFor ({ state : "visible" , timeout : TIMEOUT });
result . checks . permissionModes = await page . locator ( "[data-page-ai-pi-lab-permission-mode]" ). evaluateAll (( nodes ) => nodes . map (( node ) => ({
mode : node . getAttribute ( "data-page-ai-pi-lab-permission-mode" ),
text : node . textContent . trim (),
active : node . getAttribute ( "data-active" ),
})));
assert . deepEqual ( result . checks . permissionModes . map (( item ) => item . mode ), [ "confirm" , "auto_edit" , "plan" , "full_access" ]);
assert . equal ( result . checks . permissionModes . find (( item ) => item . mode === "plan" ). active , "true" , "plan menu item should be active" );
await page . screenshot ({ path : path . join ( OUT , "01-plan-mode-menu-active.png" ), fullPage : false });
result . screenshots . planModeMenu = path . join ( OUT , "01-plan-mode-menu-active.png" );
await page . keyboard . press ( "Escape" ). catch (() => null );
const patchAttempt = await requestJson ( page , "/api/page-ai/pi/tool-call" , {
method : "POST" ,
allowStatus : true ,
data : {
sessionId : session . sessionId ,
toolName : "mnote.local_file.patch" ,
params : {
rootUri : ROOT_URI ,
path : TARGET_PATH ,
operations : [{ op : "replace" , content : "PLAN_MODE_SHOULD_NOT_WRITE\n" }],
},
},
});
result . checks . planPatchHttpStatus = patchAttempt . response . status ();
result . checks . planPatchBody = patchAttempt . body ;
assert . equal ( patchAttempt . response . ok (), false , "plan mode should reject local_file.patch before approval" );
assert . equal ( patchAttempt . body . code , "page_ai_pi_lab_tool_denied_by_permission_mode" , "plan mode should deny patch tool by policy" );
const prompt = [
"这是计划模式浏览器 smoke。" ,
`请不要真正修改文件,只分析如果要修改 ${ TARGET_PATH } 应该怎么做。` ,
"如果当前是计划模式,请说明需要切换到自动编辑或完全访问后才能执行写入。" ,
]. join ( "\n" );
const sendRequestPromise = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/send" ) && request . method () === "POST" ,
{ timeout : TIMEOUT },
);
const sendResponsePromise = page . waitForResponse (
( response ) => response . url (). includes ( "/api/page-ai/pi/send" ) && response . request (). method () === "POST" ,
{ timeout : TIMEOUT },
);
await page . locator ( "[data-page-ai-pi-lab-input]" ). fill ( prompt );
await page . locator ( "[data-page-ai-pi-lab-btn-send]" ). click ();
const sendBody = ( await sendRequestPromise ). postDataJSON ();
const sendResponse = await sendResponsePromise ;
const sendPayload = await sendResponse . json ();
result . checks . planSendOriginalMessage = sendBody . message ;
result . checks . planSendPermissionMode = sendPayload . permissionMode ;
result . checks . planModePromptApplied = sendPayload . planModePromptApplied ;
assert . equal ( sendBody . message , prompt , "UI should send the user's original text" );
assert . equal ( sendPayload . permissionMode , "plan" , "send response should remain in plan mode" );
assert . equal ( sendPayload . planModePromptApplied , true , "backend should apply plan-mode readonly prompt wrapper" );
const sessionJsonl = await readLatestSessionJsonl ( session . piSessionDir );
result . session . sessionFile = sessionJsonl . sessionFile ;
const sessionTexts = messageTextsFromSessionJsonl ( sessionJsonl . raw );
result . checks . rpcCommandContainsPlanWrapper = sessionTexts . some (( text ) => text . includes ( "当前是 MNote Pi 计划模式" ));
result . checks . rpcCommandPreservesOriginalPrompt = sessionTexts . some (( text ) => text . includes ( prompt ));
assert . equal ( result . checks . rpcCommandContainsPlanWrapper , true , "RPC command should contain the readonly plan-mode wrapper" );
assert . equal ( result . checks . rpcCommandPreservesOriginalPrompt , true , "RPC command should include the user's original prompt inside the plan-mode wrapper" );
await page . waitForTimeout ( 2000 );
result . checks . targetContentAfterPlan = fs . readFileSync ( TARGET_ABS , "utf8" );
result . checks . noPermissionRequiredPrompt = await page . locator ( "text=Permission Required" ). count () === 0 ;
assert . equal ( result . checks . targetContentAfterPlan , TARGET_ORIGINAL , "plan mode should not mutate the target file" );
assert . equal ( result . checks . noPermissionRequiredPrompt , true , "plan mode denial should not show an ask prompt" );
await page . screenshot ({ path : path . join ( OUT , "02-plan-mode-send-applied.png" ), fullPage : false });
result . screenshots . planModeSend = path . join ( OUT , "02-plan-mode-send-applied.png" );
await requestJson ( page , "/api/page-ai/pi/abort" , { method : "POST" , data : { sessionId : session . sessionId } }). catch (() => null );
assert . equal ( consoleMessages . length , 0 , `console errors: ${ consoleMessages . join ( "\n" ) } ` );
result . ok = true ;
} catch ( error ) {
result . ok = false ;
result . error = error && error . stack ? error . stack : String ( error );
try {
await page . screenshot ({ path : path . join ( OUT , "99-failure.png" ), fullPage : true });
result . screenshots . failure = path . join ( OUT , "99-failure.png" );
} catch {}
process . exitCode = 1 ;
} finally {
fs . writeFileSync ( path . join ( OUT , "result.json" ), JSON . stringify ( result , null , 2 ), "utf8" );
await browser . close ();
console . log ( JSON . stringify ({ ok : result . ok , outputDir : OUT , result : path . join ( OUT , "result.json" ) }, null , 2 ));
}
}
main ();