2026-07-10 10:54:34 +08:00
#!/usr/bin/env node
"use strict" ;
2026-07-28 17:04:27 +08:00
const { loginViaAuthForm } = require ( './lib/browser-auth-login' );
2026-07-10 10:54:34 +08:00
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 {
setupWorkspaceAccess ,
seedAiPolicy ,
} = require ( "./lib/control-plane-dev-seed" );
const BASE = process . env . MNOTE_UI_BASE_URL || "http://127.0.0.1:3000" ;
const STAMP = Date . now ();
const OUT = process . env . MNOTE_PI_INPUT_CONTROLS_OUT || path . join ( os . tmpdir (), `mnote-pi-input-controls- ${ 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_INPUT_WORKSPACE_ID || `local-ws: ${ ACTOR_ID } :pi-input-controls` ;
const ROOT_PATH = process . env . MNOTE_PI_INPUT_ROOT_PATH || path . join ( OUT , "workspace" );
const ROOT_URI = process . env . MNOTE_PI_INPUT_ROOT_URI || `file:// ${ ROOT_PATH } ` ;
const PAGE_DIR = `pi-input-controls- ${ STAMP } ` ;
const PAGE_PATH = ` ${ PAGE_DIR } /pi-input-controls- ${ STAMP } .md` ;
const ROOT_PAGE_PATH = `pi-input-controls-root- ${ STAMP } .md` ;
const MODEL_PROVIDER = process . env . MNOTE_PI_INPUT_MODEL_PROVIDER || "omniroute" ;
2026-07-11 20:39:18 +08:00
const MODEL_ID = process . env . MNOTE_PI_INPUT_MODEL_ID || "gpt-5.4-mini" ;
2026-07-10 10:54:34 +08:00
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 ) {
2026-07-28 17:04:27 +08:00
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
const base =
( typeof BASE_URL !== "undefined" && BASE_URL ) ||
( typeof baseUrl !== "undefined" && baseUrl ) ||
process . env . MNOTE_UI_BASE_URL ||
"http://127.0.0.1:3000" ;
const timeout =
( typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS ) ||
( typeof TIMEOUT !== "undefined" && TIMEOUT ) ||
30_000 ;
if ( ! String ( page . url () || "" ). includes ( "/auth" )) {
await page . goto ( String ( base ). replace ( /\/+$/ , "" ) + "/auth" , {
waitUntil : "commit" ,
timeout ,
});
}
await loginViaAuthForm ( page , {
baseUrl : base ,
timeoutMs : timeout ,
gotoAuth : false ,
});
await page
. waitForURL (( url ) => ! String ( url ). includes ( "/auth" ), { timeout })
. catch (() => {});
2026-07-10 10:54:34 +08:00
}
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 policyForInputControls () {
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 : {},
};
}
async function seedWorkspace ( page ) {
mkdirp ( ROOT_PATH );
mkdirp ( path . dirname ( path . join ( ROOT_PATH , PAGE_PATH )));
fs . writeFileSync (
path . join ( ROOT_PATH , PAGE_PATH ),
[
"# Pi input controls smoke" ,
"" ,
"CURRENT_PAGE_QUICK_CONTENT_OK" ,
"This file proves current-page quick read used a real allowed root." ,
"" ,
]. join ( "\n" ),
"utf8" ,
);
fs . writeFileSync (
path . join ( ROOT_PATH , ROOT_PAGE_PATH ),
[
"# Pi input controls root page" ,
"" ,
"ROOT_PAGE_FOLDER_CONTEXT_OK" ,
"" ,
]. join ( "\n" ),
"utf8" ,
);
await setupWorkspaceAccess ( page . request , BASE , {
actorId : ACTOR_ID ,
email : "mnote.e2e@example.com" ,
username : ACTOR_ID ,
displayName : ACTOR_ID ,
role : "admin" ,
workspaceId : WORKSPACE_ID ,
workspaceName : "Pi input controls smoke" ,
rootPath : ROOT_PATH ,
rootUri : ROOT_URI ,
permission : "write" ,
capabilities : [ "ai" , "read" , "write" ],
timeoutMs : TIMEOUT ,
});
await seedAiPolicy ( page . request , BASE , {
id : `pi-input-controls-policy- ${ ACTOR_ID } - ${ WORKSPACE_ID } ` ,
userId : ACTOR_ID ,
workspaceId : WORKSPACE_ID ,
allowedRootsJson : [{ rootUri : ROOT_URI , rootPath : ROOT_PATH , permission : "write" }],
modelPolicyJson : policyForInputControls (),
quotaJson : { daily : 200 },
timeoutMs : TIMEOUT ,
});
}
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 ;
await requestJson ( page , "/api/page-ai/pi/abort" , { method : "POST" , data : { sessionId } }). catch (() => null );
}
async function startSession ( page , sessionId , thinkingLevel , pagePath = PAGE_PATH ) {
const start = await requestJson ( page , "/api/page-ai/pi/start" , {
method : "POST" ,
data : {
sessionId ,
rootUri : ROOT_URI ,
workspaceId : WORKSPACE_ID ,
pagePath ,
pageTitle : "Pi input controls smoke" ,
modelProvider : MODEL_PROVIDER ,
modelId : MODEL_ID ,
thinkingLevel ,
},
});
assert . equal ( start . ok , true , "Pi start ok should be true" );
assert . equal ( start . session . thinkingLevel , thinkingLevel , "start should persist requested thinkingLevel" );
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 (() => !! window . __mnotePiLabTest , null , { timeout : TIMEOUT });
}
async function emit ( page , payload ) {
await page . evaluate (( eventPayload ) => {
window . __mnotePiLabTest . emitRpcEvent ( eventPayload );
}, payload );
}
async function composerValue ( page ) {
return page . locator ( "[data-page-ai-pi-lab-input]" ). inputValue ();
}
async function clickQuickAndAssertComposer ( page , kind , pattern , label ) {
await page . locator ( `[data-page-ai-pi-lab-quick=" ${ kind } "]` ). click ();
await page . waitForFunction (
({ selector , source }) => new RegExp ( source ). test ( document . querySelector ( selector ) ? . value || "" ),
{ selector : "[data-page-ai-pi-lab-input]" , source : pattern . source },
{ timeout : TIMEOUT },
);
const value = await composerValue ( page );
assert ( pattern . test ( value ), ` ${ label } did not update composer: ${ value . slice ( 0 , 300 ) } ` );
}
async function clickQuickAndAssertActive ( page , kind , expectedActive , label ) {
const before = await composerValue ( page );
const requestSeen = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/tool-call" ) || request . url (). includes ( "/api/page-ai/pi/send" ),
{ timeout : 900 },
). then (( request ) => request . url ()). catch (() => null );
await page . locator ( `[data-page-ai-pi-lab-quick=" ${ kind } "]` ). click ();
await page . waitForFunction (
({ kind : targetKind , expected }) => document . querySelector ( `[data-page-ai-pi-lab-quick=" ${ targetKind } "]` ) ? . getAttribute ( "data-active" ) === String ( expected ),
{ kind , expected : expectedActive },
{ timeout : TIMEOUT },
);
const after = await composerValue ( page );
const unexpectedRequest = await requestSeen ;
assert . equal ( after , before , ` ${ label } should toggle context state without changing composer` );
assert . equal ( unexpectedRequest , null , ` ${ label } should not send or call a tool on click: ${ unexpectedRequest } ` );
}
async function clickMenuContextAndAssertActive ( page , action , expectedActive , label ) {
const before = await composerValue ( page );
const requestSeen = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/tool-call" ) || request . url (). includes ( "/api/page-ai/pi/send" ),
{ timeout : 900 },
). then (( request ) => request . url ()). catch (() => null );
await openActionMenu ( page );
await page . locator ( `[data-page-ai-pi-lab-menu-action=" ${ action } "]` ). click ();
await page . waitForFunction (
({ action : targetAction , expected }) => document . querySelector ( `[data-page-ai-pi-lab-menu-action=" ${ targetAction } "]` ) ? . getAttribute ( "data-active" ) === String ( expected ),
{ action , expected : expectedActive },
{ timeout : TIMEOUT },
);
const after = await composerValue ( page );
const unexpectedRequest = await requestSeen ;
assert . equal ( after , before , ` ${ label } should toggle context state without changing composer` );
assert . equal ( unexpectedRequest , null , ` ${ label } should not send or call a tool on click: ${ unexpectedRequest } ` );
}
async function quickActive ( page , kind ) {
return page . locator ( `[data-page-ai-pi-lab-quick=" ${ kind } "]` ). getAttribute ( "data-active" );
}
async function openActionMenu ( page ) {
const toggle = page . locator ( "[data-page-ai-pi-lab-action-menu-toggle]" );
await toggle . click ();
const menu = page . locator ( "[data-page-ai-pi-lab-action-menu]" );
await menu . waitFor ({ state : "visible" , timeout : TIMEOUT });
return menu ;
}
2026-07-11 20:39:18 +08:00
async function waitForSendEnabled ( page ) {
await page . waitForFunction (() => {
const button = document . querySelector ( "[data-page-ai-pi-lab-btn-send]" );
return button && ! button . disabled ;
}, null , { timeout : TIMEOUT });
}
2026-07-10 10:54:34 +08:00
async function sendViaMenuAndCapture ( page , action , text ) {
await page . locator ( "[data-page-ai-pi-lab-input]" ). fill ( text );
await emit ( page , { type : "message_update" , assistantMessageEvent : { type : "text_start" } });
const requestPromise = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/send" ) && request . method () === "POST" ,
{ timeout : TIMEOUT },
);
await openActionMenu ( page );
await page . locator ( `[data-page-ai-pi-lab-menu-action=" ${ action } "]` ). click ();
const request = await requestPromise ;
return request . postDataJSON ();
}
async function main () {
mkdirp ( OUT );
const browser = await chromium . launch ({
headless : process . env . MNOTE_PI_INPUT_CONTROLS_HEADED === "1" ? false : true ,
executablePath : CHROMIUM_EXECUTABLE || undefined ,
});
const context = await browser . newContext ({ viewport : { width : 1440 , height : 980 } });
await context . addInitScript (() => {
window . __MNOTE_PI_LAB_TEST__ = true ;
});
const page = await context . newPage ();
const consoleMessages = [];
page . on ( "console" , ( message ) => {
if ([ "error" , "warning" ]. includes ( message . type ())) consoleMessages . push ( ` ${ message . type () } : ${ message . text () } ` );
});
2026-07-11 20:39:18 +08:00
page . on ( "response" , ( response ) => {
if ( response . status () >= 400 ) {
const postData = response . request (). postData ();
consoleMessages . push ( `response: ${ response . status () } ${ response . url () }${ postData ? ` body= ${ postData } ` : "" } ` );
}
});
2026-07-10 10:54:34 +08:00
page . on ( "pageerror" , ( error ) => consoleMessages . push ( `pageerror: ${ error . message } ` ));
const result = {
base : BASE ,
outputDir : OUT ,
rootUri : ROOT_URI ,
pagePath : PAGE_PATH ,
screenshots : {},
checks : {},
consoleMessages ,
};
try {
await quickLogin ( page );
await seedWorkspace ( page );
await abortExistingSession ( page );
const session = await startSession ( page , `pi-input-controls- ${ STAMP } ` , "high" );
result . session = {
sessionId : session . sessionId ,
runtimeMode : session . runtimeMode ,
thinkingLevel : session . thinkingLevel ,
};
await openPiUi ( page );
2026-07-11 20:39:18 +08:00
await page . waitForFunction (() => document . querySelector ( "[data-page-ai-pi-lab-thinking-label]" ) ? . textContent ? . includes ( "思考 高" ), null , {
timeout : TIMEOUT ,
});
2026-07-10 10:54:34 +08:00
2026-07-11 20:39:18 +08:00
const contextProbe = await page . evaluate (({ rootUri , workspaceId , pagePath }) => {
const originalRuntime = window . __mnoteDocumentPaneRuntime ;
const readContext = ( activeEditor ) => {
window . __mnoteDocumentPaneRuntime = {
getOpenEditorsSnapshot () {
return { activeEditor };
},
};
return window . __mnotePiLabTest . getCurrentContext ();
};
const directory = readContext ({
documentId : "local-folder:.opencode" ,
workspacePath : {
documentId : "local-folder:.opencode" ,
relativePath : ".opencode" ,
resourceKind : "directory" ,
rootUri ,
workspaceId ,
},
});
const markdown = readContext ({
documentId : `local-md: ${ pagePath } ` ,
workspacePath : {
documentId : `local-md: ${ pagePath } ` ,
relativePath : pagePath ,
resourceKind : "page" ,
rootUri ,
workspaceId ,
},
});
if ( originalRuntime === undefined ) delete window . __mnoteDocumentPaneRuntime ;
else window . __mnoteDocumentPaneRuntime = originalRuntime ;
return { directory , markdown };
}, { rootUri : ROOT_URI , workspaceId : WORKSPACE_ID , pagePath : PAGE_PATH });
result . checks . standaloneDirectoryPagePath = contextProbe . directory . pagePath ;
result . checks . standaloneMarkdownPagePath = contextProbe . markdown . pagePath ;
assert . equal ( contextProbe . directory . pagePath , "" , "standalone Pi page must not attach a directory as current page" );
assert . equal ( contextProbe . markdown . pagePath , PAGE_PATH , "standalone Pi page should follow the active Markdown page" );
const toolOnlyId = `tool-only- ${ STAMP } ` ;
await emit ( page , {
type : "tool_execution_start" ,
toolCallId : toolOnlyId ,
toolName : "todo" ,
args : {},
});
await emit ( page , {
type : "tool_execution_end" ,
toolCallId : toolOnlyId ,
toolName : "todo" ,
result : { content : [{ type : "text" , text : "No todos" }] },
isError : false ,
});
await emit ( page , {
type : "message_end" ,
message : { role : "assistant" , content : [], stopReason : "stop" },
});
await emit ( page , {
type : "agent_end" ,
messages : [{ role : "assistant" , content : [], stopReason : "stop" }],
});
const toolOnlyReply = page . locator ( '[data-page-ai-pi-lab-message-role="assistant"]' ). last ();
await toolOnlyReply . waitFor ({ state : "visible" , timeout : TIMEOUT });
result . checks . toolOnlyReply = (( await toolOnlyReply . textContent ()) || "" ). trim ();
result . checks . emptyReplyErrorCount = await page . getByText ( "Pi runtime 返回了空回复" , { exact : false }). count ();
assert . match ( result . checks . toolOnlyReply , /todo(完成)/ , "tool-only turn should finish with a visible tool summary" );
assert . equal ( result . checks . emptyReplyErrorCount , 0 , "message_end and agent_end must not duplicate an empty-reply error" );
result . checks . thinkingInitialLabel = ( await page . locator ( "[data-page-ai-pi-lab-thinking-label]" ). textContent () || "" ). trim ();
assert ( result . checks . thinkingInitialLabel . includes ( "思考 高" ), "thinking label should reflect current session: " + result . checks . thinkingInitialLabel );
2026-07-10 10:54:34 +08:00
result . checks . permissionLabel = ( await page . locator ( "[data-page-ai-pi-lab-permission-label]" ). textContent () || "" ). trim ();
assert ( /确认|审批|受限|自动/ . test ( result . checks . permissionLabel ), `permission label missing: ${ result . checks . permissionLabel } ` );
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 . permissionMenuVisible = true ;
result . checks . permissionModes = await page . locator ( "[data-page-ai-pi-lab-permission-mode]" ). evaluateAll (( nodes ) => nodes . map (( node ) => node . getAttribute ( "data-page-ai-pi-lab-permission-mode" )));
assert . deepEqual ( result . checks . permissionModes , [ "confirm" , "auto_edit" , "plan" , "full_access" ]);
const modeStartRequestPromise = page . waitForRequest (
2026-07-11 20:39:18 +08:00
( request ) => {
if ( ! request . url (). includes ( "/api/page-ai/pi/start" ) || request . method () !== "POST" ) return false ;
try {
return request . postDataJSON () ? . permissionMode === "auto_edit" ;
} catch {
return false ;
}
},
{ timeout : Math . min ( TIMEOUT , 30000 ) },
2026-07-10 10:54:34 +08:00
);
await page . locator ( '[data-page-ai-pi-lab-permission-mode="auto_edit"]' ). click ();
const modeStartBody = ( await modeStartRequestPromise ). postDataJSON ();
result . checks . permissionAutoEditStartMode = modeStartBody . permissionMode ;
result . checks . permissionAutoEditStartSessionId = modeStartBody . sessionId ;
assert . equal ( modeStartBody . permissionMode , "auto_edit" , "mode switch should apply through /api/page-ai/pi/start" );
assert . equal ( modeStartBody . sessionId , session . sessionId , "mode switch should keep the current Pi session" );
await page . waitForFunction (() => / 自动编辑 / . test ( document . querySelector ( "[data-page-ai-pi-lab-permission-label]" ) ? . textContent || "" ), null , { timeout : TIMEOUT });
result . checks . permissionAutoEditSelected = true ;
result . checks . noRestartRuntimeHint = await page . locator ( '[title*="重启 Pi runtime"]' ). count () === 0 ;
assert . equal ( result . checks . noRestartRuntimeHint , true , "Pi controls should not ask the user to restart Pi runtime" );
await page . screenshot ({ path : path . join ( OUT , "00-permission-mode-auto-applied.png" ), fullPage : false });
result . screenshots . permissionModeAutoApplied = path . join ( OUT , "00-permission-mode-auto-applied.png" );
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 });
const fullAccessStartRequestPromise = page . waitForRequest (
2026-07-11 20:39:18 +08:00
( request ) => {
if ( ! request . url (). includes ( "/api/page-ai/pi/start" ) || request . method () !== "POST" ) return false ;
try {
return request . postDataJSON () ? . permissionMode === "full_access" ;
} catch {
return false ;
}
},
{ timeout : Math . min ( TIMEOUT , 30000 ) },
2026-07-10 10:54:34 +08:00
);
await page . locator ( '[data-page-ai-pi-lab-permission-mode="full_access"]' ). click ();
const fullAccessStartBody = ( await fullAccessStartRequestPromise ). postDataJSON ();
result . checks . permissionFullAccessStartMode = fullAccessStartBody . permissionMode ;
result . checks . permissionFullAccessStartSessionId = fullAccessStartBody . sessionId ;
assert . equal ( fullAccessStartBody . permissionMode , "full_access" , "full access mode should apply through /api/page-ai/pi/start" );
assert . equal ( fullAccessStartBody . sessionId , session . sessionId , "full access switch should keep the current Pi session" );
await page . waitForFunction (() => / 完全访问 / . test ( document . querySelector ( "[data-page-ai-pi-lab-permission-label]" ) ? . textContent || "" ), null , { timeout : TIMEOUT });
const currentPageTool = await requestJson ( page , "/api/page-ai/pi/tool-call" , {
method : "POST" ,
data : {
sessionId : session . sessionId ,
toolName : "mnote.current_page.read" ,
params : { rootUri : ROOT_URI , pagePath : PAGE_PATH },
},
});
result . checks . fullAccessCurrentPageToolOk = currentPageTool . ok ;
result . checks . fullAccessCurrentPageApprovalRequired = currentPageTool . approvalRequired ;
result . checks . fullAccessCurrentPageContent = currentPageTool . result && currentPageTool . result . content ;
assert . equal ( currentPageTool . ok , true , "full access should allow current page read without MNote approval" );
assert . equal ( currentPageTool . approvalRequired , false , "full access should not require approval for current page read" );
assert . match ( String ( currentPageTool . result && currentPageTool . result . content || "" ), /CURRENT_PAGE_QUICK_CONTENT_OK/ , "current page read should return seeded page content" );
await page . waitForTimeout ( 600 );
result . checks . noPermissionRequiredPromptInFullAccess = await page . locator ( "text=Permission Required" ). count () === 0 ;
assert . equal ( result . checks . noPermissionRequiredPromptInFullAccess , true , "full access should not show Permission Required prompt for current page read" );
await page . screenshot ({ path : path . join ( OUT , "00b-permission-full-access-current-page-no-prompt.png" ), fullPage : false });
result . screenshots . permissionFullAccessNoPrompt = path . join ( OUT , "00b-permission-full-access-current-page-no-prompt.png" );
result . checks . currentPageInitialActive = await page . locator ( '[data-page-ai-pi-lab-quick="read-page"]' ). getAttribute ( "data-active" );
assert . equal ( result . checks . currentPageInitialActive , "false" , "no context should be selected by default" );
await clickQuickAndAssertActive ( page , "read-page" , true , "bottom current-page context on" );
result . checks . bottomCurrentPageOn = true ;
await clickQuickAndAssertActive ( page , "read-page" , false , "bottom current-page context off" );
result . checks . bottomCurrentPageOff = true ;
await clickQuickAndAssertActive ( page , "current-folder" , true , "bottom current-folder context on without page" );
result . checks . folderOnDoesNotRestorePage = await quickActive ( page , "read-page" );
assert . equal ( result . checks . folderOnDoesNotRestorePage , "false" , "folder on should not restore current page" );
await clickQuickAndAssertActive ( page , "current-folder" , false , "bottom current-folder context off without page" );
result . checks . folderOffDoesNotRestorePage = await quickActive ( page , "read-page" );
assert . equal ( result . checks . folderOffDoesNotRestorePage , "false" , "folder off should not restore current page" );
await startSession ( page , `pi-input-controls-root- ${ STAMP } ` , "high" , ROOT_PAGE_PATH );
await openPiUi ( page );
const rootFolderToastPromise = page . locator ( "text=当前页没有可用文件夹上下文" ). waitFor ({ state : "visible" , timeout : 900 }). catch (() => null );
await clickQuickAndAssertActive ( page , "current-folder" , true , "bottom current-folder context on root page" );
result . checks . rootPageCurrentFolderActive = await quickActive ( page , "current-folder" );
result . checks . rootPageFolderUnavailableToast = Boolean ( await rootFolderToastPromise );
assert . equal ( result . checks . rootPageCurrentFolderActive , "true" , "root-level page should allow workspace root folder context" );
assert . equal ( result . checks . rootPageFolderUnavailableToast , false , "root-level page should not warn about missing folder context" );
await clickQuickAndAssertActive ( page , "current-folder" , false , "bottom current-folder context off root page" );
await startSession ( page , session . sessionId , "high" );
await openPiUi ( page );
await clickQuickAndAssertActive ( page , "read-page" , true , "bottom current-page context on again" );
result . checks . bottomCurrentPageOnAgain = true ;
await clickQuickAndAssertActive ( page , "current-folder" , true , "bottom current-folder context" );
result . checks . bottomCurrentFolder = true ;
await clickQuickAndAssertActive ( page , "selection" , true , "bottom selection context" );
result . checks . bottomSelection = true ;
await clickQuickAndAssertActive ( page , "rag" , true , "bottom LightRAG context" );
result . checks . bottomRag = true ;
await openActionMenu ( page );
result . checks . attachDisabled = await page . locator ( '[data-page-ai-pi-lab-menu-action="attach-file"]' ). isDisabled ();
result . checks . cameraDisabled = await page . locator ( '[data-page-ai-pi-lab-menu-action="camera"]' ). isDisabled ();
result . checks . planModeAvailable = await page . locator ( '[data-page-ai-pi-lab-menu-action="plan-mode"]:not(:disabled)' , { hasText : "计划评审" }). count () === 1 ;
await page . locator ( '[data-page-ai-pi-lab-menu-action="plan-mode"]' ). click ();
await page . waitForFunction (() => / \ / plan | plan - mode | 计划评审 / . test ( document . querySelector ( "[data-page-ai-pi-lab-input]" ) ? . value || "" ), null , { timeout : TIMEOUT });
result . checks . planModeComposer = await composerValue ( page );
await clickMenuContextAndAssertActive ( page , "rag" , false , "menu LightRAG context off" );
result . checks . menuRagOff = true ;
await clickMenuContextAndAssertActive ( page , "rag" , true , "menu LightRAG context on" );
result . checks . menuRagOn = true ;
await clickMenuContextAndAssertActive ( page , "selection" , false , "menu selection context off" );
result . checks . menuSelectionOff = true ;
await clickMenuContextAndAssertActive ( page , "selection" , true , "menu selection context on" );
result . checks . menuSelectionOn = true ;
await clickMenuContextAndAssertActive ( page , "current-folder" , false , "menu current-folder context off" );
result . checks . menuCurrentFolderOff = true ;
await clickMenuContextAndAssertActive ( page , "current-folder" , true , "menu current-folder context on" );
result . checks . menuCurrentFolderOn = true ;
await clickMenuContextAndAssertActive ( page , "read-page" , false , "menu current-page context off" );
result . checks . menuReadPageOff = true ;
await clickMenuContextAndAssertActive ( page , "read-page" , true , "menu current-page context on" );
result . checks . menuReadPageOn = true ;
await page . screenshot ({ path : path . join ( OUT , "01-context-actions-working.png" ), fullPage : false });
result . screenshots . contextActions = path . join ( OUT , "01-context-actions-working.png" );
const steerBody = await sendViaMenuAndCapture ( page , "send-steer" , "steer input controls smoke" );
result . checks . menuSteerStreamingBehavior = steerBody . streamingBehavior ;
assert . equal ( steerBody . streamingBehavior , "steer" , "menu steer should send streamingBehavior=steer" );
const followBody = await sendViaMenuAndCapture ( page , "send-followup" , "follow-up input controls smoke" );
result . checks . menuFollowupStreamingBehavior = followBody . streamingBehavior ;
assert . equal ( followBody . streamingBehavior , "followUp" , "menu follow-up should send streamingBehavior=followUp" );
await page . screenshot ({ path : path . join ( OUT , "02-streaming-send-menu-working.png" ), fullPage : false });
result . screenshots . streamingSendMenu = path . join ( OUT , "02-streaming-send-menu-working.png" );
await page . locator ( "[data-page-ai-pi-lab-history]" ). click ();
await page . locator ( `[data-page-ai-pi-lab-history-row=" ${ session . sessionId } "]` ). waitFor ({ state : "visible" , timeout : TIMEOUT });
await page . locator ( `[data-page-ai-pi-lab-history-session=" ${ session . sessionId } "]` ). first (). click ();
await page . waitForFunction (
( expectedSessionId ) => document . querySelector ( `[data-page-ai-pi-lab-history-row=" ${ expectedSessionId } "]` ) ? . getAttribute ( "data-active" ) === "true" ,
session . sessionId ,
{ timeout : TIMEOUT },
);
result . checks . historyInputDisabled = await page . locator ( "[data-page-ai-pi-lab-input]" ). isDisabled ();
result . checks . historyReplayBannerVisible = await page . locator ( "[data-page-ai-pi-lab-replay-banner]" ). isVisible (). catch (() => false );
assert . equal ( result . checks . historyInputDisabled , false , "opening history should keep composer editable" );
assert . equal ( result . checks . historyReplayBannerVisible , false , "opening history should not show readonly replay banner" );
const historyStartRequestPromise = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/start" ) && request . method () === "POST" ,
{ timeout : TIMEOUT },
2026-07-11 20:39:18 +08:00
). catch (( error ) => error );
2026-07-10 10:54:34 +08:00
const historySendRequestPromise = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/send" ) && request . method () === "POST" ,
{ timeout : TIMEOUT },
2026-07-11 20:39:18 +08:00
). catch (( error ) => error );
2026-07-10 10:54:34 +08:00
await page . locator ( "[data-page-ai-pi-lab-input]" ). fill ( "history continue input controls smoke" );
2026-07-11 20:39:18 +08:00
await waitForSendEnabled ( page );
2026-07-10 10:54:34 +08:00
await page . locator ( "[data-page-ai-pi-lab-btn-send]" ). click ();
2026-07-11 20:39:18 +08:00
const historyStartRequest = await historyStartRequestPromise ;
const historySendRequest = await historySendRequestPromise ;
if ( historyStartRequest instanceof Error ) throw historyStartRequest ;
if ( historySendRequest instanceof Error ) throw historySendRequest ;
const historyStartBody = historyStartRequest . postDataJSON ();
const historySendBody = historySendRequest . postDataJSON ();
2026-07-10 10:54:34 +08:00
result . checks . historyContinueStartSessionId = historyStartBody . sessionId ;
result . checks . historyContinueSendSessionId = historySendBody . sessionId ;
result . checks . historyContinueMessage = historySendBody . message ;
assert . equal ( historyStartBody . sessionId , session . sessionId , "continuing history should restart the opened session" );
assert . equal ( historySendBody . sessionId , session . sessionId , "continuing history should send to the opened session" );
assert . equal ( historySendBody . message , "history continue input controls smoke" , "history continue should send composer text" );
await page . screenshot ({ path : path . join ( OUT , "03-history-session-continues.png" ), fullPage : false });
result . screenshots . historySessionContinues = path . join ( OUT , "03-history-session-continues.png" );
await page . locator ( "[data-page-ai-pi-lab-new]" ). click ();
2026-07-11 20:39:18 +08:00
await page . locator ( "[data-page-ai-pi-lab-thinking-menu-toggle]" ). click ();
await page . locator ( "[data-page-ai-pi-lab-thinking-menu]" ). waitFor ({ state : "visible" , timeout : TIMEOUT });
await page . locator ( '[data-page-ai-pi-lab-thinking-option="xhigh"]' ). click ();
2026-07-10 10:54:34 +08:00
const startRequestPromise = page . waitForRequest (
( request ) => request . url (). includes ( "/api/page-ai/pi/start" ) && request . method () === "POST" ,
{ timeout : TIMEOUT },
);
await page . locator ( "[data-page-ai-pi-lab-input]" ). fill ( "auto start thinking level smoke" );
await page . locator ( "[data-page-ai-pi-lab-btn-send]" ). click ();
const startRequest = await startRequestPromise ;
const startBody = startRequest . postDataJSON ();
result . checks . startThinkingLevel = startBody . thinkingLevel ;
assert . equal ( startBody . thinkingLevel , "xhigh" , "thinking selector should be sent on start" );
2026-07-11 20:39:18 +08:00
await requestJson ( page , "/api/page-ai/pi/abort" , {
method : "POST" ,
data : { sessionId : startBody . sessionId || session . sessionId },
}). catch (() => null );
await page . waitForFunction (() => {
const status = document . querySelector ( "[data-page-ai-pi-lab-status-text]" ) ? . textContent || "" ;
return ! /starting|streaming/ . test ( status );
}, null , { timeout : TIMEOUT }). catch (() => null );
await startSession ( page , session . sessionId , "high" );
await openPiUi ( page );
await clickQuickAndAssertActive ( page , "read-page" , true , "send current-page context on after new conversation" );
await clickQuickAndAssertActive ( page , "current-folder" , true , "send current-folder context on after new conversation" );
await clickQuickAndAssertActive ( page , "selection" , true , "send selection context on after new conversation" );
await clickQuickAndAssertActive ( page , "rag" , true , "send LightRAG context on after new conversation" );
2026-07-10 10:54:34 +08:00
2026-07-11 20:39:18 +08:00
const sendRequestPromise = page . waitForRequest (
2026-07-10 10:54:34 +08:00
( request ) => request . url (). includes ( "/api/page-ai/pi/send" ) && request . method () === "POST" ,
{ timeout : TIMEOUT },
);
2026-07-11 20:39:18 +08:00
const sendResponsePromise = page . waitForResponse (
( response ) => response . url (). includes ( "/api/page-ai/pi/send" ) && response . request (). method () === "POST" ,
{ timeout : TIMEOUT },
);
2026-07-10 10:54:34 +08:00
await page . locator ( "[data-page-ai-pi-lab-input]" ). fill ( "send button input controls smoke" );
await page . locator ( "[data-page-ai-pi-lab-btn-send]" ). click ();
const sendRequest = await sendRequestPromise ;
const sendBody = sendRequest . postDataJSON ();
result . checks . sendButtonMessage = sendBody . message ;
result . checks . sendButtonRootUri = sendBody . rootUri ;
result . checks . sendButtonPagePath = sendBody . pagePath ;
result . checks . sendButtonFolderPath = sendBody . folderPath ;
result . checks . sendButtonContextRefs = sendBody . contextRefs ;
result . checks . sendButtonSelectedContext = sendBody . selectedContext ;
assert . equal ( sendBody . message , "send button input controls smoke" , "send button should post composer text" );
assert . equal ( sendBody . rootUri , ROOT_URI , "send should refresh Pi session rootUri from current page context" );
assert . equal ( sendBody . pagePath , PAGE_PATH , "send should refresh Pi session pagePath from current page context" );
assert . equal ( sendBody . folderPath , PAGE_DIR , "send should include selected current-folder path" );
assert . deepEqual ( sendBody . contextRefs , [ "current_page" , "folder" , "selection" , "lightrag" ], "send should include selected context refs" );
assert . equal ( sendBody . selectedContext . currentPage . pagePath , PAGE_PATH , "selectedContext should include current page address" );
assert . equal ( sendBody . selectedContext . currentFolder . folderPath , PAGE_DIR , "selectedContext should include current folder address" );
assert . equal ( sendBody . selectedContext . lightrag . enabled , true , "selectedContext should include LightRAG toggle" );
2026-07-11 20:39:18 +08:00
const sendResponse = await sendResponsePromise ;
const sendResponseText = await sendResponse . text (). catch (() => "" );
result . checks . sendButtonResponseStatus = sendResponse . status ();
result . checks . sendButtonResponseBody = sendResponseText . slice ( 0 , 500 );
assert ( sendResponse . ok (), `send button request should succeed: ${ sendResponse . status () } ${ sendResponseText . slice ( 0 , 500 ) } ` );
await requestJson ( page , "/api/page-ai/pi/abort" , {
method : "POST" ,
data : { sessionId : sendBody . sessionId || session . sessionId },
}). catch (() => null );
2026-07-10 10:54:34 +08:00
await openActionMenu ( page );
await Promise . all ([
page . waitForURL (( url ) => url . pathname === "/user/ai" && url . hash === "#ai-admin-access" , { timeout : TIMEOUT }),
page . locator ( '[data-page-ai-pi-lab-menu-action="directory-permission"]' ). click (),
]);
await page . locator ( "#ai-admin-access.is-active" ). waitFor ({ state : "visible" , timeout : TIMEOUT });
result . checks . directoryPermissionUrl = page . url ();
await page . screenshot ({ path : path . join ( OUT , "04-directory-permission-entry.png" ), fullPage : false });
result . screenshots . directoryPermission = path . join ( OUT , "04-directory-permission-entry.png" );
assert ( result . checks . attachDisabled , "attach-file should stay disabled until attachment context is implemented" );
assert ( result . checks . cameraDisabled , "camera should stay disabled until attachment context is implemented" );
assert ( result . checks . planModeAvailable , "plan review should be available through Pi Rust official plan-mode extension" );
assert ( /\/plan|plan-mode|计划评审/ . test ( result . checks . planModeComposer || "" ), "plan review action should write a Pi Rust official /plan command" );
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 ();