#!/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 { 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"; const MODEL_ID = process.env.MNOTE_PI_INPUT_MODEL_ID || "gpt-5.4-mini"; 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 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; } 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 }); } 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()}`); }); page.on("response", (response) => { if (response.status() >= 400) { const postData = response.request().postData(); consoleMessages.push(`response: ${response.status()} ${response.url()}${postData ? ` body=${postData}` : ""}`); } }); 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); await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-thinking-label]")?.textContent?.includes("思考 高"), null, { timeout: TIMEOUT, }); 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); 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( (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) }, ); 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( (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) }, ); 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 }, ).catch((error) => error); const historySendRequestPromise = page.waitForRequest( (request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST", { timeout: TIMEOUT }, ).catch((error) => error); await page.locator("[data-page-ai-pi-lab-input]").fill("history continue input controls smoke"); await waitForSendEnabled(page); await page.locator("[data-page-ai-pi-lab-btn-send]").click(); 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(); 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(); 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(); 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"); 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"); 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("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"); 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); 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();