312 lines
16 KiB
JavaScript
312 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const http = require("node:http");
|
|
const net = require("node:net");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { spawn } = require("node:child_process");
|
|
const { chromium } = require("playwright");
|
|
|
|
const ROOT = path.resolve(__dirname, "..");
|
|
const {
|
|
setupWorkspaceAccess,
|
|
getAiRuntimeRun,
|
|
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
|
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-"));
|
|
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
|
|
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
|
const ACTOR = "mnote-e2e";
|
|
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
|
|
|
function pickPort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const port = address && typeof address === "object" ? address.port : 0;
|
|
server.close(() => resolve(port));
|
|
});
|
|
server.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function waitForHttpOk(url, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
return new Promise((resolve, reject) => {
|
|
const tick = () => {
|
|
const request = http.get(url, (response) => {
|
|
response.resume();
|
|
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
|
|
retry();
|
|
});
|
|
request.on("error", retry);
|
|
request.setTimeout(1000, () => { request.destroy(); retry(); });
|
|
};
|
|
const retry = () => {
|
|
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
|
|
setTimeout(tick, 250);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
function writeFakeReasonixAcp(scriptPath) {
|
|
fs.writeFileSync(scriptPath, `
|
|
import * as readline from 'node:readline';
|
|
import { appendFileSync } from 'node:fs';
|
|
import { stdin as input, stdout as output } from 'node:process';
|
|
const rl = readline.createInterface({ input, output, terminal: false });
|
|
const LOG = process.env.TASK562_FAKE_LOG || '';
|
|
let nextSessionId = 1;
|
|
let pendingPrompt = null;
|
|
let permissionRequestId = 7;
|
|
let permissionId = 'task562_perm_1';
|
|
let marker = 'TASK562_DONE';
|
|
function log(value) { if (LOG) appendFileSync(LOG, JSON.stringify(value) + '\\n', 'utf8'); }
|
|
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
|
|
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
|
|
rl.on('line', (line) => {
|
|
const msg = JSON.parse(line);
|
|
log({ dir: 'in', method: msg.method || '', id: msg.id || null, hasResult: Boolean(msg.result), hasError: Boolean(msg.error) });
|
|
if (msg.method === 'initialize') {
|
|
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task562-fake-reasonix', version: '1.0' }, authMethods: [] } });
|
|
return;
|
|
}
|
|
if (msg.method === 'session/new') {
|
|
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task562_reasonix_session_' + nextSessionId++ } });
|
|
return;
|
|
}
|
|
if (msg.method === 'session/prompt') {
|
|
const sessionId = msg.params?.sessionId || '';
|
|
const prompt = promptText(msg.params?.prompt);
|
|
marker = (String(prompt).match(/TASK562_[A-Z0-9_]+/) || ['TASK562_DONE'])[0];
|
|
pendingPrompt = { id: msg.id, sessionId, prompt };
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id: permissionRequestId,
|
|
method: 'session/request_permission',
|
|
params: {
|
|
permissionId,
|
|
toolName: 'mcp__demo__write_file',
|
|
toolCall: {
|
|
toolCallId: 'gate-call_1',
|
|
title: 'bash',
|
|
kind: 'execute',
|
|
rawInput: { command: 'echo reasonix permission' }
|
|
},
|
|
options: [
|
|
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
|
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' }
|
|
]
|
|
}
|
|
});
|
|
log({ dir: 'out', method: 'session/request_permission', id: permissionRequestId, permissionId });
|
|
return;
|
|
}
|
|
if (msg.id === permissionRequestId && msg.result) {
|
|
log({ dir: 'permission-resolved', result: msg.result });
|
|
if (!pendingPrompt) return;
|
|
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: pendingPrompt.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
|
|
send({ jsonrpc: '2.0', id: pendingPrompt.id, result: { stopReason: 'end_turn' } });
|
|
pendingPrompt = null;
|
|
}
|
|
});
|
|
`, "utf8");
|
|
}
|
|
|
|
async function signIn(context, baseUrl) {
|
|
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
data: {
|
|
action: "auth:signIn",
|
|
args: {
|
|
provider: "password",
|
|
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
|
|
},
|
|
},
|
|
timeout: TIMEOUT_MS,
|
|
});
|
|
const text = await response.text();
|
|
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
|
|
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
|
|
const whoamiText = await whoami.text();
|
|
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
|
|
return JSON.parse(whoamiText);
|
|
}
|
|
|
|
async function selectReasonix(page) {
|
|
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
|
|
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
|
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS });
|
|
await page.waitForFunction(
|
|
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
|
|
null,
|
|
{ timeout: TIMEOUT_MS },
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs");
|
|
const fakeLog = path.join(OUT_DIR, "fake-reasonix-acp.jsonl");
|
|
writeFakeReasonixAcp(fakeScript);
|
|
const port = await pickPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], env: { TASK562_FAKE_LOG: fakeLog }, title: "Reasonix Fake" }]);
|
|
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
|
cwd: path.join(ROOT, "rust"),
|
|
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stderr = "";
|
|
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task562-reasonix-approval-"));
|
|
let browser;
|
|
try {
|
|
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
|
|
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
|
const page = await context.newPage();
|
|
const viewer = await signIn(context, baseUrl);
|
|
const actorId = viewer.userId || ACTOR;
|
|
const suffix = Date.now().toString(36).toUpperCase();
|
|
const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`;
|
|
const rootUri = `file://${root}`;
|
|
const relativePath = `Task562-${suffix}.md`;
|
|
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
|
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
|
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
|
|
fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8");
|
|
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
|
|
await setupWorkspaceAccess(context.request, baseUrl, {
|
|
actorId,
|
|
email: `${actorId}@example.com`,
|
|
username: actorId,
|
|
displayName: actorId,
|
|
role: "user",
|
|
workspaceId,
|
|
workspaceName: "Task562 Reasonix Approval",
|
|
rootUri,
|
|
rootPath: root,
|
|
sourceKind: "local_folder",
|
|
permission: "write",
|
|
capabilities: ["ai"],
|
|
grantSource: "smoke",
|
|
grantCreatedBy: actorId,
|
|
timeoutMs: TIMEOUT_MS,
|
|
});
|
|
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
|
|
url.searchParams.set("sourceKind", "local_folder");
|
|
url.searchParams.set("rootUri", rootUri);
|
|
url.searchParams.set("workspaceId", workspaceId);
|
|
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
|
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
|
|
await selectReasonix(page);
|
|
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
|
await page.locator('[data-page-ai-tab="reasonix-settings"]').first().click({ timeout: TIMEOUT_MS });
|
|
await page.waitForFunction(
|
|
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'reasonix-settings',
|
|
null,
|
|
{ timeout: TIMEOUT_MS },
|
|
);
|
|
|
|
const modelSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.model_id"]');
|
|
const approvalSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.approval_mode"]');
|
|
const planSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.plan_mode"]');
|
|
await modelSelect.selectOption("mimo-pro", { timeout: TIMEOUT_MS });
|
|
await approvalSelect.selectOption("ask", { timeout: TIMEOUT_MS });
|
|
await planSelect.selectOption("auto", { timeout: TIMEOUT_MS });
|
|
await page.waitForFunction(
|
|
() => {
|
|
const text = document.querySelector('[data-page-ai-model-status]')?.textContent || '';
|
|
return text.includes('Reasonix') && text.includes('mimo-pro') && text.includes('审批:询问') && text.includes('计划:自动');
|
|
},
|
|
null,
|
|
{ timeout: TIMEOUT_MS },
|
|
);
|
|
await page.evaluate(() => {
|
|
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
|
const buttons = Array.from(drawer ? drawer.querySelectorAll('[data-page-ai-tab="chat"]') : []);
|
|
const button = buttons.find((node) => node instanceof HTMLElement && node.offsetParent !== null);
|
|
if (button) button.click();
|
|
});
|
|
await page.waitForFunction(
|
|
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'chat',
|
|
null,
|
|
{ timeout: TIMEOUT_MS },
|
|
);
|
|
await page.waitForSelector('[data-page-ai-reasonix-quick-controls]:not([hidden])', { timeout: TIMEOUT_MS });
|
|
const quickControlsText = await page.locator('[data-page-ai-reasonix-quick-controls]').textContent({ timeout: TIMEOUT_MS });
|
|
assert(String(quickControlsText || '').includes('模型'), `聊天输入区应显示模型选择: ${quickControlsText}`);
|
|
assert(String(quickControlsText || '').includes('审批'), `聊天输入区应显示审批选择: ${quickControlsText}`);
|
|
assert(String(quickControlsText || '').includes('Plan'), `聊天输入区应显示 Plan 选择: ${quickControlsText}`);
|
|
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.model_id"]').inputValue({ timeout: TIMEOUT_MS }), 'mimo-pro', '模型 quick control 应同步设置值');
|
|
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.approval_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'ask', '审批 quick control 应同步设置值');
|
|
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.plan_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'auto', 'Plan quick control 应同步设置值');
|
|
|
|
const marker = `TASK562_DONE_${suffix}`;
|
|
await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS });
|
|
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
|
|
await page.waitForFunction(
|
|
() => !!document.querySelector('[data-page-ai-permission-dialog]') && !document.querySelector('[data-page-ai-permission-dialog]')?.hidden,
|
|
null,
|
|
{ timeout: TIMEOUT_MS },
|
|
);
|
|
const permissionText = await page.locator('[data-page-ai-permission-dialog]').textContent({ timeout: TIMEOUT_MS });
|
|
assert(String(permissionText || '').includes('bash') && String(permissionText || '').includes('echo reasonix permission'), `权限弹窗应展示 tool 和命令: ${permissionText}`);
|
|
const resolveRequestPromise = page.waitForRequest(
|
|
(request) => request.url().includes('/resolve-permission') && request.method() === 'POST',
|
|
{ timeout: 5000 },
|
|
).catch(() => null);
|
|
const resolveResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes('/resolve-permission'),
|
|
{ timeout: 5000 },
|
|
).catch(() => null);
|
|
await page.evaluate(() => {
|
|
const button = document.querySelector('[data-page-ai-permission-dialog] [data-page-ai-permission-action="allow"]');
|
|
if (button instanceof HTMLElement) button.click();
|
|
});
|
|
const resolveRequest = await resolveRequestPromise;
|
|
assert(resolveRequest, '点击允许后应发出 resolve-permission 请求');
|
|
const resolveResponse = await resolveResponsePromise;
|
|
assert(resolveResponse && resolveResponse.ok(), `resolve-permission 应成功,实际 status=${resolveResponse && resolveResponse.status()} url=${resolveRequest.url()} body=${resolveRequest.postData() || ''}`);
|
|
await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS });
|
|
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
|
|
|
|
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
|
|
assert(runId, '缺少 runId');
|
|
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
|
|
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker, `AI 回复不符合预期: ${JSON.stringify(text && text.slice(0, 800))}`);
|
|
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
|
|
assert(String(runtimeStrip || '').includes('Reasonix'), `runtime strip 应显示 Reasonix: ${runtimeStrip}`);
|
|
assert(String(runtimeStrip || '').includes('mimo-pro'), `runtime strip 应显示已选 model: ${runtimeStrip}`);
|
|
assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`);
|
|
assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`);
|
|
|
|
// 通过 API helper 读取 run 持久化状态,不再直读 SQLite
|
|
const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
|
|
assert(persistedRun, `run ${runId} 应 persist`);
|
|
assert.equal(persistedRun.status, 'completed', 'API run status 应为 completed');
|
|
const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png');
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
|
|
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
console.log(JSON.stringify(result, null, 2));
|
|
await context.close().catch(() => undefined);
|
|
} catch (error) {
|
|
fs.writeFileSync(path.join(OUT_DIR, 'failure.json'), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, 'utf8');
|
|
throw error;
|
|
} finally {
|
|
if (browser) await browser.close().catch(() => undefined);
|
|
server.kill('SIGTERM');
|
|
await new Promise((resolve) => server.once('exit', resolve));
|
|
if (process.env.MNOTE_KEEP_TASK562_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });
|