Files
mnote/scripts/task-hermes-page-ai-artifact-smoke.js
T

286 lines
12 KiB
JavaScript

#!/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);
});