#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const { chromium } = require("playwright"); const { BASE_URL, UI_TIMEOUT_MS, cleanupDocuments, createTempDocument, ensureAuthenticated, renameDocument, requestJson, } = require("./tree-shell-smoke-helpers"); async function callArtifact(request, target, suffix, toolName, args) { return requestJson(request, "/api/hermes/tools/mnote/call", { method: "POST", headers: { "x-mnote-actor-id": "smoke-user" }, data: { toolName, workspaceId: target.workspaceId, documentId: target.documentId, actorId: "smoke-user", sessionId: `mnote_smoke_artifact_${suffix}`, runId: `run_smoke_artifact_${suffix}`, toolCallId: `call_${toolName.replace(/\W+/g, "_")}_${suffix}`, traceId: `trace_smoke_artifact_${suffix}`, idempotencyKey: `idem_${toolName.replace(/\W+/g, "_")}_${suffix}`, dryRun: false, capabilityScope: ["artifact.write"], args, }, }); } function assertArtifactReferenceEdge(edgesPayload, sourceDocumentId, artifactDocumentId, artifactType) { const edges = Array.isArray(edgesPayload?.result?.edges) ? edgesPayload.result.edges : []; const edge = edges.find( (candidate) => candidate && candidate.fromNodeId === sourceDocumentId && candidate.toNodeId === artifactDocumentId && candidate.metadata?.kind === "ai_artifact_reference", ); assert(edge, `未查询到 ${artifactDocumentId} 的 ai_artifact_reference edge`); assert.equal(edge.edgeType, "source_of", `${artifactDocumentId} reference edge 类型不对`); assert.equal(edge.metadata.artifactType, artifactType, `${artifactDocumentId} artifactType 不对`); assert.equal(edge.metadata.projectionOnlyGroup, "AI Artifacts", `${artifactDocumentId} projection group 不对`); } async function main() { const suffix = Date.now().toString(36); const title = `TEST-HERMES-AI-artifact-${suffix}`; const quickSessionId = `mnote_smoke_artifact_quick_${suffix}`; let quickRunIndex = 0; const quickRunBodies = []; const createdIds = []; const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); try { await page.route("**/api/hermes/tools/mnote/call", async (route) => { throw new Error(`页面 AI artifact 快捷入口不应直连 mnote tool route: ${route.request().url()}`); }); await page.route("**/api/hermes/client/sessions", async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, sessionId: quickSessionId, title: "当前页问答", traceId: `trace_quick_session_${suffix}`, }), }); }); await page.route(`**/api/hermes/client/sessions/${quickSessionId}`, async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, sessionId: quickSessionId, session: { sessionId: quickSessionId, messages: [] }, }), }); }); await page.route("**/api/hermes/client/runs", async (route) => { quickRunIndex += 1; quickRunBodies.push(JSON.parse(route.request().postData() || "{}")); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, sessionId: quickSessionId, runId: `run_quick_artifact_${suffix}_${quickRunIndex}`, traceId: `trace_quick_artifact_${suffix}_${quickRunIndex}`, }), }); }); await page.route("**/api/hermes/client/events/**", async (route) => { const runId = route.request().url().split("/").pop(); await route.fulfill({ status: 200, headers: { "content-type": "text/event-stream; charset=utf-8" }, body: `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: quickSessionId, delta: "artifact intent queued" })}\n\n` + `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: quickSessionId, output: "artifact intent queued" })}\n\n`, }); }); await ensureAuthenticated(page, context.request); const target = await createTempDocument(context.request); createdIds.push(target.documentId); await renameDocument(context.request, target.workspaceId, target.documentId, title); const summary = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", { summary: `摘要 ${suffix}`, }); const summaryRetry = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", { summary: `摘要 ${suffix}`, }); const aiNote = await callArtifact(context.request, target, suffix, "mnote.artifact.create_ai_note", { content: `AI Note ${suffix}`, }); const aiNoteSecond = await callArtifact(context.request, target, `${suffix}_second`, "mnote.artifact.create_ai_note", { content: `AI Note second ${suffix}`, }); const artifactDocumentIds = Array.from( new Set([ summary.result.artifactDocumentId, aiNote.result.artifactDocumentId, aiNoteSecond.result.artifactDocumentId, ]), ); createdIds.push(...artifactDocumentIds); for (const result of [summary, aiNote, aiNoteSecond]) { assert.equal(result.ok, true, `${result.toolName} 应成功`); assert.equal(result.audit.effect, "write", `${result.toolName} audit 应为 write`); assert.equal(result.result.commandName, "tree.node.create", `${result.toolName} 必须走 tree.node.create`); assert.equal(result.audit.commandId, result.result.commandId, `${result.toolName} audit commandId 必须指向 Rust commandId`); assert(result.result.artifactDocumentId, `${result.toolName} 缺少 artifactDocumentId`); assert.equal(result.result.referenceEdge?.from, target.documentId, `${result.toolName} reference edge from 不对`); assert.equal(result.result.referenceEdge?.to, result.result.artifactDocumentId, `${result.toolName} reference edge to 不对`); assert.equal(result.result.referenceEdge?.kind, "ai_artifact_reference", `${result.toolName} reference edge kind 不对`); assert(result.result.artifacts?.commandLog, `${result.toolName} 缺少 commandLog artifact`); assert(result.result.artifacts?.domainEvent, `${result.toolName} 缺少 domainEvent artifact`); assert.equal( result.result.artifacts.domainEvent.eventType, "tree.node.created", `${result.toolName} domain event 类型不对`, ); } assert.equal(summaryRetry.result.commandId, summary.result.commandId, "summary 同 idempotencyKey 重试必须返回同一 commandId"); assert.equal( summaryRetry.result.artifactDocumentId, summary.result.artifactDocumentId, "summary 同页面重试必须指向同一 summary document", ); assert.notEqual( aiNoteSecond.result.artifactDocumentId, aiNote.result.artifactDocumentId, "ai_note 多次创建必须生成独立 artifact document", ); const edgePayload = await requestJson( context.request, `/api/kernel/edges?workspaceId=${encodeURIComponent(target.workspaceId)}&nodeId=${encodeURIComponent(target.documentId)}`, { method: "GET" }, ); assertArtifactReferenceEdge(edgePayload, target.documentId, summary.result.artifactDocumentId, "summary"); assertArtifactReferenceEdge(edgePayload, target.documentId, aiNote.result.artifactDocumentId, "ai_note"); assertArtifactReferenceEdge(edgePayload, target.documentId, aiNoteSecond.result.artifactDocumentId, "ai_note"); const fileProjection = await requestJson( context.request, `/api/tree/projections/file?workspaceId=${encodeURIComponent(target.workspaceId)}&rootNodeId=${encodeURIComponent(target.documentId)}&depth=2`, { method: "GET" }, ); const projectionItems = Array.isArray(fileProjection?.result?.items) ? fileProjection.result.items : []; const aiArtifactsMeta = fileProjection?.result?.meta?.aiArtifacts || {}; assert.equal(aiArtifactsMeta.title, "AI Artifacts", "AI Artifacts projection meta 标题不对"); assert.equal(aiArtifactsMeta.projectionOnly, true, "AI Artifacts 必须是 projection-only 分组"); assert.equal(aiArtifactsMeta.kernelNodeId, null, "AI Artifacts 不应是真实 kernel node"); assert( !projectionItems.some((item) => item && item.nodeId === "AI Artifacts"), "AI Artifacts 不应出现在 projection items 中作为真实 node", ); for (const artifactDocumentId of artifactDocumentIds) { assert( aiArtifactsMeta.artifactDocumentIds?.includes(artifactDocumentId), `AI Artifacts projection meta 缺少 ${artifactDocumentId}`, ); } const auditPayload = await requestJson( context.request, `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(`trace_smoke_artifact_${suffix}`)}`, { method: "GET", headers: { "x-mnote-actor-id": "smoke-user" }, }, ); const auditEvents = Array.isArray(auditPayload.events) ? auditPayload.events : []; for (const result of [summary, aiNote]) { assert( auditEvents.some( (event) => event && event.phase === "completed" && event.toolCallId === result.toolCallId && event.audit?.commandId === result.result.commandId, ), `${result.toolName} audit 未串到 Hermes tool call 和 Rust command`, ); } await page.goto( `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, ); await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-intent="create-summary"]').click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction( () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("artifact intent queued"), undefined, { timeout: UI_TIMEOUT_MS }, ); await page.locator('[data-page-ai-intent="create-ai-note"]').click({ timeout: UI_TIMEOUT_MS }); const deadline = Date.now() + UI_TIMEOUT_MS; while (quickRunBodies.length < 2 && Date.now() < deadline) { await page.waitForTimeout(100); } assert.equal(quickRunBodies.length, 2, "两个 artifact 快捷入口都必须发送 Hermes run intent"); assert( quickRunBodies[0].message.includes("mnote.artifact.create_summary"), "创建 Summary 快捷入口必须发送 Hermes artifact summary intent", ); assert( quickRunBodies[1].message.includes("mnote.artifact.create_ai_note"), "创建 AI Note 快捷入口必须发送 Hermes artifact ai_note intent", ); assert( quickRunBodies.every((body) => body.documentId === target.documentId && body.sessionId === quickSessionId), "artifact 快捷入口必须携带当前页面 documentId 与 Hermes sessionId", ); console.log( JSON.stringify( { ok: true, baseUrl: BASE_URL, documentId: target.documentId, workspaceId: target.workspaceId, summaryArtifactType: summary.result.artifactType, aiNoteArtifactType: aiNote.result.artifactType, summaryCommand: summary.result.commandName, aiNoteCommand: aiNote.result.commandName, summaryArtifactDocumentId: summary.result.artifactDocumentId, aiNoteArtifactDocumentId: aiNote.result.artifactDocumentId, aiNoteSecondArtifactDocumentId: aiNoteSecond.result.artifactDocumentId, referenceEdgeCount: edgePayload.result.edges.length, aiArtifactsMeta, auditEventCount: auditEvents.length, quickIntentMessages: quickRunBodies.map((body) => body.message), }, 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); });