#!/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 { BASE_URL, UI_TIMEOUT_MS, ensureAuthenticated, } = require("./tree-shell-smoke-helpers"); const TASK = "task523-page-ai-onlyoffice-real-target-session-smoke"; const OUT_DIR = path.join(process.cwd(), "tmp", TASK); const SCREENSHOT_DIR = path.join(OUT_DIR, "screenshots"); const RESULT_PATH = path.join(OUT_DIR, "result.json"); const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx"; const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); function fileUrl(localPath) { return `file://${localPath}`; } function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } function writeWorkspaceManifest(root, ownerId, workspaceId) { fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); fs.writeFileSync( path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "ai_sessions", "markdown_edit"], }, null, 2)}\n`, "utf8", ); } function documentUrl(root, relativePath) { const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); url.searchParams.set("treeView", "filetree"); return url.toString(); } function localOfficeFileUrl(root, relativePath) { const url = new URL(`${BASE_URL}/api/local-folder/files/open`); url.searchParams.set("rootUri", fileUrl(root)); url.searchParams.set("path", relativePath); return url.toString(); } function onlyofficeUrl(root, pageRelativePath, officeRelativePath, assetId) { const url = new URL(`${BASE_URL}/onlyoffice`); url.searchParams.set("fileUrl", localOfficeFileUrl(root, officeRelativePath)); url.searchParams.set("fileName", path.basename(officeRelativePath)); url.searchParams.set("fileType", "docx"); url.searchParams.set("assetId", assetId); url.searchParams.set("documentId", localMdDocumentId(pageRelativePath)); url.searchParams.set("mode", "edit"); return url.toString(); } async function saveScreenshot(page, name) { const target = path.join(SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path: target, fullPage: true }); return target; } async function waitForOfficeSnapshot(page, objectIdentity) { return await page.waitForFunction( (targetId) => { const runtime = window.__mnoteDocumentPaneRuntime; if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") return null; const snapshot = runtime.getOpenEditorsSnapshot(); const resources = Array.isArray(snapshot && snapshot.resourceEditors) ? snapshot.resourceEditors : []; const entry = resources.find((item) => item && item.objectIdentity === targetId); if (!entry || !entry.bridgeSessionReady || !(entry.onlyofficeSessionId || entry.bridgeSessionId)) return null; return entry; }, objectIdentity, { timeout: UI_TIMEOUT_MS }, ); } async function main() { assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测 docx: ${PROBE_DOCX_PATH}`); fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); const suffix = Date.now().toString(36); const actorId = "mnote-e2e"; const workspaceId = `local-ws:${actorId}:task523`; const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task523-onlyoffice-target-")); const rootUri = fileUrl(root); const pageRelativePath = "Page/Page.md"; const officeRelativePath = "Page/office-a.docx"; const documentId = localMdDocumentId(pageRelativePath); const assetId = `local-file:${officeRelativePath}`; const objectIdentity = `resource:office:${documentId}:${assetId}`; const captured = []; const consoleErrors = []; const networkFailures = []; const httpErrors = []; const result = { ok: false, task: TASK, baseUrl: BASE_URL, root, documentId, officeRelativePath, objectIdentity, screenshots: [], consoleErrors, networkFailures, httpErrors, }; fs.mkdirSync(path.join(root, "Page"), { recursive: true }); writeWorkspaceManifest(root, actorId, workspaceId); fs.writeFileSync( path.join(root, pageRelativePath), ["# Page", "", `Task523 ${suffix}`, ""].join("\n"), "utf8", ); fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, officeRelativePath)); const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN", extraHTTPHeaders: { "x-mnote-actor-id": actorId, "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); page.on("requestfailed", (request) => { networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" }); }); page.on("response", (response) => { if (response.status() >= 400) { httpErrors.push({ url: response.url(), status: response.status() }); } }); try { await page.route("**/api/user/access-policy**", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, controlPlane: "sqlite", grants: [{ id: `grant_task523_${suffix}`, userId: actorId, workspaceId, rootUri, rootPath: root, permission: "write", recursive: true, capabilities: ["ai", "markdown_edit", "office.write"], source: "user", status: "active", }], }), }); }); await page.route("**/api/ui/preferences**", async (route) => { captured.push({ kind: "ui-preferences", method: route.request().method(), body: route.request().postData() || "" }); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }), }); }); await page.route("**/api/hermes/client/gateway/health**", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, gateway: { ok: true, status: "mocked" }, profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true }, suggestions: [], }), }); }); await page.route("**/api/hermes/client/tools**", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, tools: [] }), }); }); await page.route("**/api/ai/agent-profiles**", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, agentId: "reasonix", profiles: [ { profileId: "shared_doubao_chat", kind: "shared", displayName: "豆包 Chat", baseProfile: "doubao-chat", isolatedProfile: "openclaw-doubao-chat", canRun: true, canManageSkills: false, canManageConfig: false, readonly: true }, { profileId: "usr_task523_default", kind: "personal", displayName: "我的 Hermes", baseProfile: "default", isolatedProfile: "mnote-u-task523-default", canRun: true, canManageSkills: true, canManageConfig: true }, ], }), }); }); await page.route("**/api/hermes/client/profiles", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, active: "mnoteai", profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }], }), }); }); await page.route("**/api/hermes/client/skills**", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, categories: [], archived: [] }), }); }); await page.route("**/api/hermes/client/capabilities**", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }), }); }); await page.route("**/api/hermes/client/sessions", async (route) => { captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" }); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, sessionId: `mnote_task523_${suffix}`, title: "task523", traceId: `trace_task523_session_${suffix}`, persistence: "local_ai_session_jsonl", sessionStorage: "local_private", }), }); }); await page.route("**/api/hermes/client/runs", async (route) => { captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" }); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, sessionId: `mnote_task523_${suffix}`, runId: `run_task523_${suffix}`, events: [], traceId: `trace_task523_run_${suffix}`, }), }); }); await page.route("**/api/hermes/client/events/*", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "text/event-stream; charset=utf-8" }, body: `data: ${JSON.stringify({ event: "message.delta", run_id: `run_task523_${suffix}`, delta: "Task523 response" })}\n\n` + `data: ${JSON.stringify({ event: "run.completed", run_id: `run_task523_${suffix}`, output: "Task523 response" })}\n\n`, }); }); await ensureAuthenticated(page, context.request); const response = await page.goto(documentUrl(root, pageRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`); await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); const officeHref = onlyofficeUrl(root, pageRelativePath, officeRelativePath, assetId); const openResult = await page.evaluate(async ({ objectIdentity, assetId, documentId, workspaceId, rootUri, officeRelativePath, officeHref }) => { const runtime = window.__mnoteDocumentPaneRuntime; if (!runtime || typeof runtime.openResourceInActiveTab !== "function") { throw new Error("缺少 openResourceInActiveTab runtime"); } return await runtime.openResourceInActiveTab({ objectIdentity, assetId, title: "Task523 Office", fileName: "office-a.docx", kind: "office", editorKind: "office", href: officeHref, officeUrl: officeHref, documentId, workspaceId, rootUri, sourceKind: "local_folder", path: officeRelativePath, workspacePath: { schema: "mnote.workspace_path.v1", workspaceId, sourceKind: "local_folder", rootUri, relativePath: officeRelativePath, documentId, objectIdentity, assetId, resourceKind: "only_office", }, paneRole: "primary", }); }, { objectIdentity, assetId, documentId, workspaceId, rootUri, officeRelativePath, officeHref }); assert.strictEqual(openResult, true, "openResourceInActiveTab 应成功打开 Office resource tab"); const officeHandle = await waitForOfficeSnapshot(page, objectIdentity); const officeSnapshot = await officeHandle.jsonValue(); const onlyofficeSessionId = String(officeSnapshot.onlyofficeSessionId || officeSnapshot.bridgeSessionId || "").trim(); assert(onlyofficeSessionId, `Office snapshot 必须携带 bridge session: ${JSON.stringify(officeSnapshot)}`); assert.strictEqual(officeSnapshot.bridgeSessionReady, true, "Office snapshot 应标记 bridgeSessionReady"); assert.strictEqual(officeSnapshot.assetId, assetId, `Office snapshot assetId 不匹配: ${JSON.stringify(officeSnapshot)}`); assert.strictEqual(officeSnapshot.bridgeAssetId, assetId, `Office iframe debug assetId 应透传到 snapshot: ${JSON.stringify(officeSnapshot)}`); result.officeSnapshot = officeSnapshot; result.screenshots.push(await saveScreenshot(page, "00-office-resource-tab-ready")); await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const targetButton = page.locator("[data-page-ai-target-button]"); await targetButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await targetButton.click({ timeout: UI_TIMEOUT_MS }); const targetPopover = page.locator("[data-page-ai-target-popover]"); await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await targetPopover.locator(`[data-page-ai-target-option="${objectIdentity}"]`).click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction( () => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task523 Office"), null, { timeout: UI_TIMEOUT_MS }, ); result.screenshots.push(await saveScreenshot(page, "01-page-ai-office-target-selected")); await page.locator("[data-page-ai-input]").fill(`Task523 OnlyOffice target ${suffix}`, { timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction( () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task523 response"), null, { timeout: UI_TIMEOUT_MS }, ); const runPayloads = captured.filter((item) => item.kind === "run"); assert(runPayloads.length >= 1, "未捕获 Page AI run payload"); const runBody = JSON.parse(runPayloads[runPayloads.length - 1].body); const targetPackage = runBody.targetPackage || {}; const packageTarget = Array.isArray(targetPackage.targets) ? targetPackage.targets.find((target) => target.targetId === objectIdentity) : null; assert.strictEqual(runBody.editorTarget?.targetId, objectIdentity, `editorTarget 应冻结 Office target: ${JSON.stringify(runBody.editorTarget)}`); assert.strictEqual(runBody.editorTarget?.resourceKind, "only_office", `editorTarget 应归一为 only_office: ${JSON.stringify(runBody.editorTarget)}`); assert.strictEqual(runBody.editorTarget?.onlyofficeSessionId, onlyofficeSessionId, `editorTarget 应携带 iframe live session: ${JSON.stringify(runBody.editorTarget)}`); assert.strictEqual(targetPackage.schema, "mnote.agent_target_package.v1", "targetPackage schema 不匹配"); assert.strictEqual(targetPackage.primaryTargetId, objectIdentity, `targetPackage 应冻结 Office target: ${JSON.stringify(targetPackage)}`); assert.strictEqual(targetPackage.onlyofficeSessionId, onlyofficeSessionId, `targetPackage 顶层应携带 live session: ${JSON.stringify(targetPackage)}`); assert(packageTarget, `targetPackage.targets 应包含 Office target: ${JSON.stringify(targetPackage)}`); assert.strictEqual(packageTarget.resourceKind, "only_office", `targetPackage target 应归一为 only_office: ${JSON.stringify(packageTarget)}`); assert.strictEqual(packageTarget.relativePath, officeRelativePath, `targetPackage target 应携带 Office 相对路径: ${JSON.stringify(packageTarget)}`); assert.strictEqual(packageTarget.assetId, assetId, `targetPackage target 应携带 assetId: ${JSON.stringify(packageTarget)}`); assert.strictEqual(packageTarget.onlyofficeSessionId, onlyofficeSessionId, `targetPackage target 应携带 live session: ${JSON.stringify(packageTarget)}`); assert( Array.isArray(targetPackage.allowedFiles) && targetPackage.allowedFiles.includes(officeRelativePath), `targetPackage.allowedFiles 应只按选中 Office target 授权: ${JSON.stringify(targetPackage)}`, ); assert.strictEqual( httpErrors.filter((item) => item.url.includes("/api/documents/buffer-state")).length, 0, `Office target 不应触发 Markdown buffer-state 查询: ${JSON.stringify(httpErrors)}`, ); Object.assign(result, { ok: true, onlyofficeSessionId, runBody: { agentId: runBody.agentId, editorTarget: runBody.editorTarget, targetPackage, }, }); fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } catch (error) { result.error = error && error.stack ? error.stack : String(error); fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); throw error; } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });