Files
mnote/scripts/task558-page-ai-reasonix-live-session-context-smoke.js
T

249 lines
12 KiB
JavaScript

#!/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 ROOT = path.resolve(__dirname, "..");
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
getAiRuntimeRun,
countAiRuntimeEvents,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-"));
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
async function signIn(context) {
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
},
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
const whoami = await context.request.fetch(`${BASE_URL}/api/auth/whoami`, { timeout: UI_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: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function newestAssistantText(page, beforeCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'));
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
return node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
}, beforeCount);
}
function normalize(text) {
return String(text || "").replace(/\s+/g, "").trim();
}
async function sendPrompt(page, prompt, expectedCompact) {
const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: Math.max(UI_TIMEOUT_MS, 120_000) },
);
const status = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "");
assert.equal(status, "completed", `Page AI run 应完成,实际 status=${status}`);
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: UI_TIMEOUT_MS });
const text = await newestAssistantText(page, before);
assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`);
const runId = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-id") || "");
assert(runId, "缺少 data-mnote-page-ai-run-id");
return { runId, text };
}
async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) {
const start = Date.now();
let last = null;
while (Date.now() - start < timeoutMs) {
last = await predicate().catch((error) => error);
if (last === true) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-"));
const rootUri = `file://${root}`;
const relativePath = `Task558-${suffix}.md`;
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
const 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 capturedRuns = [];
page.on("request", (request) => {
if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return;
try {
capturedRuns.push(JSON.parse(request.postData() || "{}"));
} catch {
capturedRuns.push({ raw: request.postData() || "" });
}
});
try {
const viewer = await signIn(context);
const actorId = viewer.userId || ACTOR;
const workspaceId = `local-ws:${actorId}:task558-${suffix}`;
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), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8");
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task558 Reasonix Live",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: UI_TIMEOUT_MS,
});
const url = new URL(`${BASE_URL}/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: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await selectReasonix(page);
const firstMarker = `TASK558_FIRST_${suffix}`.toUpperCase();
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
await page.locator("[data-page-ai-input]").fill(
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说"可以",你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => ["queued", "running", "tool_calling"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator("[data-page-ai-input]").fill("可以", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => Boolean(document.querySelector('[data-page-ai-queue-item]')),
null,
{ timeout: UI_TIMEOUT_MS },
);
const queuedPreview = await page.locator('[data-page-ai-queue-item]').first().textContent({ timeout: UI_TIMEOUT_MS });
assert(String(queuedPreview || '').includes("可以"), `queued preview 应包含第二轮短回复: ${queuedPreview}`);
await waitUntil("reasonix_two_runs_started", async () => capturedRuns.length >= 2, Math.max(UI_TIMEOUT_MS, 120_000));
await page.waitForFunction(
([firstNeedle, secondNeedle]) => {
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return text.includes(firstNeedle)
&& text.includes(secondNeedle)
&& ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "")
&& !document.querySelector('[data-page-ai-streaming="true"]');
},
[firstMarker, secondMarker],
{ timeout: Math.max(UI_TIMEOUT_MS, 120_000) },
);
const assistantTexts = await page.evaluate((countBefore) => {
return Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'))
.slice(countBefore)
.map((node) => node.querySelector(".wolai-page-ai-message-text")?.textContent || "");
}, beforeAssistantCount);
assert(assistantTexts.some((text) => normalize(text) === firstMarker), `第一轮回复缺失: ${JSON.stringify(assistantTexts)}`);
assert(assistantTexts.some((text) => normalize(text) === secondMarker), `第二轮回复缺失: ${JSON.stringify(assistantTexts)}`);
assert.equal(capturedRuns.length, 2, `应捕获两次 Page AI run,实际 ${capturedRuns.length}`);
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
// 通过 API helper + capturedRuns 验证 DB 持久化和 acpSessionId 连续性,不再直读 SQLite
const firstRunId = capturedRuns[0].runId;
const secondRunId = capturedRuns[1].runId;
const first = { runId: firstRunId, text: firstMarker };
const second = { runId: secondRunId, text: secondMarker };
const firstRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: firstRunId });
const secondRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: secondRunId });
assert(firstRun, `first run ${firstRunId} 应 persist`);
assert(secondRun, `second run ${secondRunId} 应 persist`);
const infoCount1 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: firstRunId, eventType: "session.info.updated" });
const infoCount2 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: secondRunId, eventType: "session.info.updated" });
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(acpSessionIds)}`);
assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id");
const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = {
ok: true,
baseUrl: BASE_URL,
outDir: OUT_DIR,
root,
rootUri,
workspaceId,
documentId,
first,
second,
queuedPreview,
acpSessionId: acpSessionIds[0],
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
screenshotPath,
};
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(OUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), capturedRuns }, null, 2)}\n`, "utf8");
throw error;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
if (process.env.MNOTE_KEEP_TASK558_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});