#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const { BASE_URL, cleanupDocuments, createTempDocument, ensureAuthenticated, openDocument, renameDocument, requestJson, } = require("./tree-shell-smoke-helpers"); const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-context-format-smoke"); async function callMnoteTool(request, payload) { return requestJson(request, "/api/mnote/tools/call", { method: "POST", headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, data: payload, }); } function toolByName(manifest, name) { const tools = manifest?.manifest?.tools || manifest?.tools || []; const tool = tools.find((candidate) => candidate.name === name); assert(tool, `manifest 缺少工具:${name}`); return tool; } function assertAnnotationShape(tool, expected) { assert(tool.annotations, `${tool.name} 缺少 annotations`); for (const key of [ "readonly", "destructive", "idempotent", "requiresApproval", "approvalMode", "runtimeOwner", "writeOwner", "selectionEffect", ]) { assert(Object.hasOwn(tool.annotations, key), `${tool.name} annotations 缺少 ${key}`); } for (const [key, value] of Object.entries(expected)) { assert.deepEqual(tool.annotations[key], value, `${tool.name} annotations.${key} 不符合预期`); } } function assertIncludes(value, fragment, message) { assert(String(value || "").includes(fragment), message); } function assertExcludes(value, fragment, message) { assert(!String(value || "").includes(fragment), message); } async function main() { const suffix = Date.now().toString(36); const title = `TEST-AI-CONTEXT-FORMAT-${suffix}`; const createdIds = []; const evidence = { ok: false, baseUrl: BASE_URL, title, steps: [], }; await fs.mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); try { const viewer = await ensureAuthenticated(page, context.request); const actorId = viewer.userId || "smoke-user"; const target = await createTempDocument(context.request); createdIds.push(target.documentId); evidence.documentId = target.documentId; evidence.workspaceId = target.workspaceId; await renameDocument(context.request, target.workspaceId, target.documentId, title); const manifest = await requestJson(context.request, "/api/mnote/tools/manifest", { method: "GET", headers: { "x-mnote-actor-id": actorId }, }); const docFetchTool = toolByName(manifest, "mnote.doc.fetch"); const blockFetchTool = toolByName(manifest, "mnote.block.fetch"); const blockReplaceTool = toolByName(manifest, "mnote.block.replace"); const pageSaveTool = toolByName(manifest, "mnote.page.save"); assertAnnotationShape(docFetchTool, { readonly: true, destructive: false, runtimeOwner: "mnote-web", writeOwner: "rust-runtime-kernel", selectionEffect: "preserve", }); assertAnnotationShape(blockFetchTool, { readonly: true, destructive: false, selectionEffect: "preserve", }); assertAnnotationShape(blockReplaceTool, { readonly: false, destructive: false, selectionEffect: "may_change", }); assertAnnotationShape(pageSaveTool, { readonly: false, destructive: true, approvalMode: "yolo", selectionEffect: "may_change", }); evidence.steps.push({ name: "manifest.annotations", checkedTools: [docFetchTool.name, blockFetchTool.name, blockReplaceTool.name, pageSaveTool.name], pageSaveDestructive: pageSaveTool.annotations.destructive, }); const seed = await callMnoteTool(context.request, { toolName: "mnote.page.save", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_context_seed_${suffix}`, runId: `run_context_seed_${suffix}`, toolCallId: `call_context_seed_${suffix}`, traceId: `trace_context_seed_${suffix}`, idempotencyKey: `idem_context_seed_${suffix}`, dryRun: false, capabilityScope: ["page.write"], args: { mode: "replace", content: [ { id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] }, { id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] }, { id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] }, ], }, }); assert.equal(seed.ok, true, "初始化 page.save 应成功"); evidence.steps.push({ name: "seed", commandName: seed.result.commandName }); const selectionXml = await callMnoteTool(context.request, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_context_fetch_${suffix}`, runId: `run_context_fetch_xml_${suffix}`, toolCallId: `call_context_fetch_xml_${suffix}`, traceId: `trace_context_fetch_xml_${suffix}`, capabilityScope: ["page.read"], args: { scope: "selection", selectedBlockIds: ["p_2"], format: "page_xml", detail: "with_ids", maxBlocks: 10, }, }); assert.equal(selectionXml.result.schema, "mnote.page_ai_context.v1", "doc.fetch 应返回 page AI context schema"); assert.equal(selectionXml.result.scope, "selection", "doc.fetch 应保留 selection scope"); assert.equal(selectionXml.result.format, "page_xml", "doc.fetch 应返回 page_xml format"); assert.deepEqual(selectionXml.result.allowedTargetBlockIds, ["p_2"], "selection 应冻结 allowedTargetBlockIds"); assert.equal(selectionXml.result.blocks.length, 1, "selection 只应返回选中块"); assert.equal(selectionXml.result.blocks[0].blockId, "p_2", "selection 应返回 p_2"); assert(selectionXml.result.blocks[0].revisionRef, "selection block 必须带 revisionRef"); assertIncludes(selectionXml.result.content, ' block.blockId), contextAfter: blockXml.result.context.after.map((block) => block.blockId), }); const outOfScopeResponse = await context.request.fetch(`${BASE_URL}/api/mnote/tools/call`, { method: "POST", headers: { "content-type": "application/json", "x-mnote-actor-id": actorId, }, data: JSON.stringify({ toolName: "mnote.doc.apply_block_ops", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_context_scope_${suffix}`, runId: `run_context_scope_${suffix}`, toolCallId: `call_context_scope_${suffix}`, traceId: `trace_context_scope_${suffix}`, idempotencyKey: `idem_context_scope_${suffix}`, dryRun: true, capabilityScope: ["block.write"], args: { allowedTargetBlockIds: ["p_2"], operations: [ { op: "replace", blockId: "p_1", content: `越界修改 ${suffix}`, }, ], }, }), }); const outOfScopeText = await outOfScopeResponse.text(); assert.equal(outOfScopeResponse.status(), 400, "选区外写入应被 Rust tool 拒绝"); assertIncludes(outOfScopeText, "mnote_block_target_out_of_scope", "选区外写入应返回明确错误码"); evidence.steps.push({ name: "write.out_of_scope.blocked", status: outOfScopeResponse.status(), errorCode: "mnote_block_target_out_of_scope", }); await openDocument(page, target.workspaceId, target.documentId); await page.getByText(`第二段 ${suffix}`).waitFor({ state: "visible", timeout: 30_000 }); const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); evidence.screenshot = screenshotPath; evidence.ok = true; const evidencePath = path.join(OUT_DIR, `${suffix}.json`); await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); } finally { await cleanupDocuments(context.request, createdIds).catch(() => undefined); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); });