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_FULL_ACCESS_ASK_USER_OUT || path . join ( os . tmpdir (), `mnote-pi-full-access-ask-user- ${ STAMP } ` );
const TIMEOUT = Number . parseInt ( process . env . UI_TIMEOUT_MS || "360000" , 10 );
const ACTOR_ID = process . env . MNOTE_E2E_ACTOR_ID || "mnote-e2e" ;
const WORKSPACE_ID = process . env . MNOTE_PI_FULL_ACCESS_ASK_USER_WORKSPACE_ID || "local-ws:mnote-e2e:my-space" ;
const ROOT_PATH = process . env . MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space" ;
const ROOT_URI = process . env . MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_URI || `file:// ${ ROOT_PATH } ` ;
const MODEL_PROVIDER = process . env . MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_PROVIDER || "omniroute" ;
2026-07-11 20:39:18 +08:00
const MODEL_ID = process . env . MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_ID || "gpt-5.4-mini" ;
2026-07-10 10:54:34 +08:00
const MARKER = `PI_FULL_ACCESS_ASK_USER_OK_ ${ STAMP } ` ;
const PAGE_PATH = `pi-full-access-ask-user- ${ STAMP } .md` ;
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 };
}
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 policyForFullAccessAskUser () {
return {
defaultModel : ` ${ MODEL_PROVIDER } / ${ MODEL_ID } ` ,
allowedModels : [ ` ${ MODEL_PROVIDER } / ${ MODEL_ID } ` ],
tools : {
"mnote.current_page.read" : "allow" ,
"mnote.allowed_roots.describe" : "allow" ,
"mnote.tool_receipt.write" : "allow" ,
},
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 full access question smoke\n" , "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 : {
... policyForFullAccessAskUser (),
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 startRealPi ( page ) {
const sessionId = `pi-full-access-ask-user- ${ 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 full access question smoke" ,
modelProvider : MODEL_PROVIDER ,
modelId : MODEL_ID ,
thinkingLevel : "medium" ,
permissionMode : "full_access" ,
},
});
assert . equal ( start . ok , true , "Pi start ok should be true" );
assert . equal ( start . permissionMode , "full_access" , "start response should expose full_access mode" );
assert . equal ( start . session . runtimePolicySnapshot . permissionMode , "full_access" , "runtime policy should persist full_access" );
assert . equal ( start . session . runtimeMode , "rpc" , `Pi 必须以 rpc 模式启动,实际= ${ start . session . runtimeMode } ` );
assert ( start . session . runtimePid , "真实 Pi RPC 启动后应有 runtimePid" );
return start . session ;
}
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 (() => {
const text = document . querySelector ( "[data-page-ai-pi-lab-status-text]" ) ? . textContent || "" ;
return /ready|running|streaming/ . test ( text );
}, null , { timeout : TIMEOUT }). catch (() => null );
}
function readSessionJsonl ( sessionDir ) {
const files = [];
const walk = ( dir ) => {
for ( const entry of fs . readdirSync ( dir , { withFileTypes : true })) {
const full = path . join ( dir , entry . name );
if ( entry . isDirectory ()) {
walk ( full );
} else if ( entry . isFile () && entry . name . endsWith ( ".jsonl" )) {
files . push ( full );
}
}
};
walk ( sessionDir );
files . sort ();
const sessionFile = files [ files . length - 1 ];
assert ( sessionFile , `session jsonl not found in ${ sessionDir } ` );
return { sessionFile , raw : fs . readFileSync ( sessionFile , "utf8" ) };
}
async function answerVisibleDialog ( page , result ) {
const dialog = page . locator ( "[data-page-ai-pi-lab-ui-dialog]" ). first ();
await dialog . waitFor ({ state : "visible" , timeout : TIMEOUT });
const method = await dialog . getAttribute ( "data-page-ai-pi-lab-ui-dialog" );
if ( ! result . screenshots . askUserDialog ) {
await page . screenshot ({ path : path . join ( OUT , "02-ask-user-dialog.png" ), fullPage : false });
result . screenshots . askUserDialog = path . join ( OUT , "02-ask-user-dialog.png" );
const dialogBox = await dialog . boundingBox ();
const inputWrapBox = await page . locator ( ".wolai-page-ai-pi-lab-input-wrap" ). boundingBox ();
result . checks . askUserDialogMethod = method ;
result . checks . askUserDialogAboveInput = !! dialogBox && !! inputWrapBox && dialogBox . y + dialogBox . height <= inputWrapBox . y + 1 ;
result . checks . askUserDialogInComposer = await page . locator ( ".wolai-page-ai-pi-lab-composer [data-page-ai-pi-lab-ui-dialog]" ). count () > 0 ;
}
if ( method === "select" ) {
await dialog . locator ( "[data-page-ai-pi-lab-ui-option]" ). first (). click ();
return ;
}
if ( method === "input" || method === "editor" ) {
await dialog . locator ( "[data-page-ai-pi-lab-ui-input]" ). fill ( "继续验证" );
await dialog . locator ( "[data-page-ai-pi-lab-ui-submit]" ). click ();
return ;
}
if ( method === "confirm" ) {
await dialog . locator ( "[data-page-ai-pi-lab-ui-submit]" ). click ();
return ;
}
if ( method === "ask_user" || method === "ask-user" || method === "questionnaire" || method === "custom" ) {
await dialog . locator ( "[data-page-ai-pi-lab-ui-option]" ). first (). click ();
await dialog . locator ( "[data-page-ai-pi-lab-ui-submit]" ). click ();
return ;
}
throw new Error ( `unsupported extension UI dialog method: ${ method } ` );
}
async function main () {
mkdirp ( OUT );
const browser = await chromium . launch ({
headless : process . env . MNOTE_PI_FULL_ACCESS_ASK_USER_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 = [];
const uiResponses = [];
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 } ` ));
page . on ( "request" , ( request ) => {
if ( request . url (). includes ( "/api/page-ai/pi/ui-response" ) && request . method () === "POST" ) {
try {
uiResponses . push ( JSON . parse ( request . postData () || "{}" ));
} catch {
uiResponses . push ({ raw : request . postData () || "" });
}
}
});
const result = {
base : BASE ,
outputDir : OUT ,
marker : MARKER ,
rootUri : ROOT_URI ,
screenshots : {},
checks : {},
consoleMessages ,
uiResponses ,
};
try {
await quickLogin ( page );
await seedWorkspace ( page );
await abortExistingSession ( page );
const session = await startRealPi ( page );
result . session = {
sessionId : session . sessionId ,
runtimePid : session . runtimePid ,
piSessionDir : session . piSessionDir ,
runtimePolicySnapshot : session . runtimePolicySnapshot ,
};
const sources = session . runtimePolicySnapshot . enabledPiExtensionSources || [];
const piExtensionToolNames = session . runtimePolicySnapshot . piExtensionToolNames || [];
result . checks . usesPiRustOfficialQuestion = sources . includes ( "pi-rust-official:question" );
result . checks . usesPiRustOfficialQuestionnaire = sources . includes ( "pi-rust-official:questionnaire" );
result . checks . questionToolAdvertised = piExtensionToolNames . includes ( "question" );
result . checks . questionnaireToolAdvertised = piExtensionToolNames . includes ( "questionnaire" );
result . checks . noLegacyNpmAskUser = ! sources . includes ( "npm:pi-ask-user" );
result . checks . retiredAskUserRemoved = ! sources . includes ( "npm:@d3ara1n/pi-ask-user" );
assert ( result . checks . usesPiRustOfficialQuestion , "runtime should use Pi Rust official question extension" );
assert ( result . checks . usesPiRustOfficialQuestionnaire , "runtime should use Pi Rust official questionnaire extension" );
assert ( result . checks . questionToolAdvertised , "runtime policy should advertise question" );
assert ( result . checks . questionnaireToolAdvertised , "runtime policy should advertise questionnaire" );
assert ( result . checks . noLegacyNpmAskUser , "runtime should not use unavailable npm:pi-ask-user" );
assert ( result . checks . retiredAskUserRemoved , "runtime should not use @d3ara1n/pi-ask-user" );
await openPiUi ( page );
await page . screenshot ({ path : path . join ( OUT , "01-full-access-ask-user-started.png" ), fullPage : false });
result . screenshots . started = path . join ( OUT , "01-full-access-ask-user-started.png" );
const prompt = [
"这是 MNote Pi Rust official question 真实链路 smoke。" ,
"你必须调用 question 工具向我提问,不要用普通文本直接问。" ,
"问题:是否继续验证 MNote Pi 完全访问下的用户问答?" ,
"提供两个选项:继续验证、停止验证。" ,
`收到我的回答后,最终单独输出一行: ${ MARKER } ` ,
]. join ( "\n" );
await page . locator ( "[data-page-ai-pi-lab-input]" ). fill ( prompt );
await page . locator ( "[data-page-ai-pi-lab-btn-send]" ). click ();
const markerLocator = page
. locator ( '[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, [data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown' )
. filter ({ hasText : MARKER })
. last ();
const startedAt = Date . now ();
let answeredDialogs = 0 ;
while ( Date . now () - startedAt < TIMEOUT ) {
const permissionVisible = await page . locator ( "text=Permission Required" ). count ();
assert . equal ( permissionVisible , 0 , "full_access should not show Permission Required for question" );
if ( await markerLocator . isVisible ({ timeout : 500 }). catch (() => false )) break ;
const dialogCount = await page . locator ( "[data-page-ai-pi-lab-ui-dialog]" ). count ();
if ( dialogCount > 0 ) {
answeredDialogs += 1 ;
assert ( answeredDialogs <= 5 , "question smoke should not require more than 5 dialogs" );
await answerVisibleDialog ( page , result );
}
await page . waitForTimeout ( 500 );
}
await markerLocator . waitFor ({ state : "visible" , timeout : 1000 });
await page . screenshot ({ path : path . join ( OUT , "03-ask-user-final-answer.png" ), fullPage : false });
result . screenshots . answer = path . join ( OUT , "03-ask-user-final-answer.png" );
const sessionJsonl = readSessionJsonl ( session . piSessionDir );
result . session . sessionFile = sessionJsonl . sessionFile ;
result . answerText = (( await markerLocator . textContent ({ timeout : TIMEOUT })) || "" ). trim ();
result . checks . answeredDialogs = answeredDialogs ;
result . checks . questionToolCalled = sessionJsonl . raw . includes ( "question" );
result . checks . extensionUiRequestRecorded = sessionJsonl . raw . includes ( "extension_ui_request" );
result . checks . extensionUiRoundTripObserved = result . checks . answeredDialogs >= 1 && result . answerText . includes ( MARKER );
result . checks . noAnswersUndefinedError = ! sessionJsonl . raw . includes ( "Cannot read properties of undefined" ) && ! ( await page . locator ( "text=Cannot read properties of undefined" ). count ());
result . checks . noPermissionRequiredPrompt = await page . locator ( "text=Permission Required" ). count () === 0 ;
assert ( result . checks . answeredDialogs >= 1 , "question should surface at least one extension UI dialog" );
assert ( result . checks . questionToolCalled , "Pi session JSONL should record question tool call" );
assert ( result . checks . extensionUiRoundTripObserved , "question should surface UI, accept an answer, and let Pi continue to final output" );
assert ( result . checks . askUserDialogInComposer , "question dialog should render inside Pi composer" );
assert ( result . checks . askUserDialogAboveInput , "question dialog should sit above the input without covering it" );
assert ( result . checks . noAnswersUndefinedError , "question should not crash with answers undefined" );
assert ( result . checks . noPermissionRequiredPrompt , "full_access should not show Permission Required prompt" );
await requestJson ( page , "/api/page-ai/pi/abort" , { method : "POST" , data : { sessionId : session . sessionId } }). catch (() => ({}));
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 ();