Files
mnote/scripts/task-pi-lab-input-controls-smoke.js
T

672 lines
35 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
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) {
// 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(() => {});
}
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();