219 lines
11 KiB
JavaScript
219 lines
11 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 { execFileSync } = require("node:child_process");
|
|
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 CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
|
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
|
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!";
|
|
|
|
function fileUrl(localPath) {
|
|
return `file://${localPath}`;
|
|
}
|
|
|
|
function sqlQuote(value) {
|
|
return `'${String(value).replaceAll("'", "''")}'`;
|
|
}
|
|
|
|
function sqliteExec(sql) {
|
|
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
}
|
|
|
|
function sqliteJson(sql) {
|
|
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
|
|
return out.trim() ? JSON.parse(out) : [];
|
|
}
|
|
|
|
function localMdDocumentId(relativePath) {
|
|
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
|
}
|
|
|
|
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
|
|
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
|
|
const now = new Date().toISOString();
|
|
sqliteExec(`
|
|
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
|
|
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
|
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
|
|
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task558 Reasonix Live', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
|
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
|
|
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
|
`);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
function sessionInfoForRuns(runIds) {
|
|
const quoted = runIds.map(sqlQuote).join(",");
|
|
return sqliteJson(`
|
|
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
|
|
FROM ai_runtime_events
|
|
WHERE run_id IN (${quoted}) AND event_type = 'session.info.updated'
|
|
ORDER BY created_at ASC, id ASC;
|
|
`).map((row) => ({
|
|
runId: row.runId,
|
|
eventType: row.eventType,
|
|
payload: JSON.parse(row.payloadJson || "{}"),
|
|
}));
|
|
}
|
|
|
|
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 = fileUrl(root);
|
|
const relativePath = `Task558-${suffix}.md`;
|
|
const documentId = localMdDocumentId(relativePath);
|
|
|
|
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");
|
|
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task558_${suffix}` });
|
|
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 first = await sendPrompt(
|
|
page,
|
|
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说“可以”,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
|
|
firstMarker,
|
|
);
|
|
const second = await sendPrompt(page, "可以", secondMarker);
|
|
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");
|
|
|
|
const infos = sessionInfoForRuns([first.runId, second.runId]);
|
|
assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`);
|
|
const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean);
|
|
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(infos)}`);
|
|
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,
|
|
acpSessionId: acpSessionIds[0],
|
|
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
|
|
sessionInfo: infos,
|
|
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);
|
|
});
|