feat(ai): switch page ai to hermes panel

This commit is contained in:
lix-2026
2026-05-14 15:10:33 +08:00
parent e9188716e6
commit 9816035491
48 changed files with 6353 additions and 412 deletions
@@ -0,0 +1,285 @@
#!/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);
});
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { chromium } = require("playwright");
const {
BASE_URL,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-HERMES-AI-audit-${suffix}`;
const marker = `TEST-HERMES-AI-AUDIT-${suffix}`;
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 ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const readTraceId = `trace_audit_read_${suffix}`;
const writeTraceId = `trace_audit_write_${suffix}`;
const deniedTraceId = `trace_audit_denied_${suffix}`;
const readToolCallId = `call_audit_read_${suffix}`;
const writeToolCallId = `call_audit_write_${suffix}`;
const deniedToolCallId = `call_audit_denied_${suffix}`;
const read = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
toolName: "mnote.page.get",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `mnote_audit_${suffix}`,
runId: `run_audit_read_${suffix}`,
toolCallId: readToolCallId,
traceId: readTraceId,
capabilityScope: ["page.read"],
},
});
assert.equal(read.audit.effect, "read", "读工具 audit effect 应为 read");
const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
toolName: "mnote.page.save",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `mnote_audit_${suffix}`,
runId: `run_audit_write_${suffix}`,
toolCallId: writeToolCallId,
traceId: writeTraceId,
idempotencyKey: `idem_audit_write_${suffix}`,
dryRun: false,
capabilityScope: ["page.write"],
args: {
mode: "replace",
content: [
{
id: `audit_block_${suffix}`,
type: "paragraph",
content: [{ type: "text", text: marker }],
},
],
},
},
});
assert.equal(write.audit.effect, "write", "写工具 audit effect 应为 write");
assert(write.audit.commandId, "写工具 audit 必须包含 commandId");
const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "smoke-user",
"x-mnote-workspace-id": "ws_denied",
},
data: JSON.stringify({
toolName: "mnote.page.save",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `mnote_audit_${suffix}`,
runId: `run_audit_denied_${suffix}`,
toolCallId: deniedToolCallId,
traceId: deniedTraceId,
idempotencyKey: `idem_audit_denied_${suffix}`,
dryRun: false,
capabilityScope: ["page.write"],
args: {
mode: "replace",
content: [
{
id: `audit_denied_block_${suffix}`,
type: "paragraph",
content: [{ type: "text", text: marker }],
},
],
},
}),
});
const deniedText = await deniedResponse.text();
assert.equal(deniedResponse.status(), 403, "权限失败 smoke 应返回 workspace_context_conflict");
assert(!deniedText.includes(title), "权限失败响应不应包含页面标题");
assert(!deniedText.includes(marker), "权限失败响应不应包含正文 marker");
const readAudit = await requestJson(
context.request,
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(readTraceId)}`,
{
method: "GET",
headers: { "x-mnote-actor-id": "smoke-user" },
},
);
const writeAudit = await requestJson(
context.request,
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}`,
{
method: "GET",
headers: { "x-mnote-actor-id": "smoke-user" },
},
);
const writePersistedAudit = await requestJson(
context.request,
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}&persistedOnly=true`,
{
method: "GET",
headers: { "x-mnote-actor-id": "smoke-user" },
},
);
const deniedPersistedAudit = await requestJson(
context.request,
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(deniedTraceId)}&persistedOnly=true`,
{
method: "GET",
headers: { "x-mnote-actor-id": "smoke-user" },
},
);
const readPhases = new Set((readAudit.events || []).map((event) => event.phase));
const writePhases = new Set((writeAudit.events || []).map((event) => event.phase));
const writePersistedPhases = new Set((writePersistedAudit.events || []).map((event) => event.phase));
const deniedPersistedPhases = new Set((deniedPersistedAudit.events || []).map((event) => event.phase));
assert(readPhases.has("started"), "读工具 audit 缺少 started");
assert(readPhases.has("completed"), "读工具 audit 缺少 completed");
assert(writePhases.has("started"), "写工具 audit 缺少 started");
assert(writePhases.has("completed"), "写工具 audit 缺少 completed");
assert(writePersistedPhases.has("started"), "持久化写工具 audit 缺少 started");
assert(writePersistedPhases.has("completed"), "持久化写工具 audit 缺少 completed");
assert(deniedPersistedPhases.has("failed"), "持久化权限失败 audit 缺少 failed");
assert(
(writeAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId),
"写工具 audit 查询结果未串到 Rust commandId",
);
assert(
(writePersistedAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId),
"持久化写工具 audit 查询结果未串到 Rust commandId",
);
assert(!JSON.stringify(writeAudit).includes(marker), "audit 查询结果不应包含完整正文内容");
assert(!JSON.stringify(writePersistedAudit).includes(marker), "持久化 audit 不应包含完整正文内容");
assert(!JSON.stringify(deniedPersistedAudit).includes(marker), "权限失败持久化 audit 不应包含完整正文内容");
assert(!JSON.stringify(deniedPersistedAudit).includes(title), "权限失败持久化 audit 不应包含页面标题");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
readTraceId,
writeTraceId,
deniedTraceId,
writeCommandId: write.audit.commandId,
readPhases: Array.from(readPhases),
writePhases: Array.from(writePhases),
writePersistedPhases: Array.from(writePersistedPhases),
deniedPersistedPhases: Array.from(deniedPersistedPhases),
},
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);
});
@@ -0,0 +1,114 @@
#!/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,
} = require("./tree-shell-smoke-helpers");
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-HERMES-AI-baseline-${suffix}`;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const captured = [];
const createdIds = [];
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/ai-agent/run") || url.includes("/api/hermes/client/")) {
captured.push({
phase: "request",
method: request.method(),
url,
postData: request.postData() || "",
});
}
});
page.on("response", async (response) => {
const url = response.url();
if (!url.includes("/api/ai-agent/run") && !url.includes("/api/hermes/client/")) {
return;
}
let body = "";
try {
body = (await response.text()).slice(0, 1600);
} catch {
body = "<unreadable>";
}
captured.push({
phase: "response",
status: response.status(),
url,
body,
headers: response.headers(),
});
});
try {
await ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
await renameDocument(context.request, target.workspaceId, target.documentId, title);
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-input]").fill("概括当前页面标题和第一段", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').length > 0,
null,
{ timeout: UI_TIMEOUT_MS },
);
const aiRequests = captured.filter((entry) => entry.phase === "request");
assert(aiRequests.length > 0, "未捕获页面 AI 主请求");
const firstRequest = aiRequests[0];
const legacyRunHit = aiRequests.some((entry) => entry.url.includes("/api/ai-agent/run"));
const hermesClientHit = aiRequests.some((entry) => entry.url.includes("/api/hermes/client/"));
const providerHermes502 = captured.some(
(entry) =>
entry.phase === "response" &&
entry.status === 502 &&
/provider|ai_provider_bridge_unavailable|hermes/i.test(entry.body || ""),
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
title,
documentId: target.documentId,
workspaceId: target.workspaceId,
firstRequestUrl: firstRequest.url,
legacyRunHit,
hermesClientHit,
providerHermes502,
captured,
},
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);
});
+144
View File
@@ -0,0 +1,144 @@
#!/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,
} = require("./tree-shell-smoke-helpers");
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-HERMES-AI-mobile-${suffix}`;
const sessionId = `mnote_mobile_${suffix}`;
const runId = `run_mobile_${suffix}`;
const createdIds = [];
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
});
const page = await context.newPage();
try {
await page.route("**/api/ai-agent/run", async (route) => {
throw new Error(`移动端页面 AI 不应请求旧 /api/ai-agent/run: ${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,
title: "移动端问答",
traceId: "trace_mobile",
persistence: "hermes_on_first_run",
}),
});
});
await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
traceId: "trace_mobile_restore",
session: {
sessionId,
messages: [{ role: "assistant", content: "Mobile restored" }],
},
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
runId,
events: [],
traceId: "trace_mobile",
}),
});
});
await page.route(`**/api/hermes/client/events/${runId}`, 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: runId, session_id: sessionId, delta: "Mobile " })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Mobile response" })}\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);
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 });
const metrics = await page.locator('[data-testid="wolai-page-ai-drawer"]').evaluate((drawer) => {
const panel = drawer.querySelector(".wolai-page-ai-panel");
const drawerRect = drawer.getBoundingClientRect();
const panelRect = panel ? panel.getBoundingClientRect() : drawerRect;
return {
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
drawerLeft: drawerRect.left,
drawerRight: drawerRect.right,
panelLeft: panelRect.left,
panelRight: panelRect.right,
};
});
assert(metrics.drawerLeft >= 0, `drawer 左侧溢出: ${JSON.stringify(metrics)}`);
assert(metrics.drawerRight <= metrics.viewportWidth + 1, `drawer 右侧溢出: ${JSON.stringify(metrics)}`);
assert(metrics.panelLeft >= 0, `panel 左侧溢出: ${JSON.stringify(metrics)}`);
assert(metrics.panelRight <= metrics.viewportWidth + 1, `panel 右侧溢出: ${JSON.stringify(metrics)}`);
assert(metrics.documentWidth <= metrics.viewportWidth + 1, `页面出现横向滚动: ${JSON.stringify(metrics)}`);
await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { 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("Mobile response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
sessionId,
runId,
metrics,
},
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);
});
@@ -0,0 +1,67 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
async function fetchWithTimeout(path, init = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(`${BASE_URL}${path}`, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
async function main() {
const response = await fetchWithTimeout("/api/ai-agent/run", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
stream: true,
messages: [{ role: "user", content: "ping" }],
context: { documentId: "retirement_guard" },
options: { ai: { provider: "hermes" } },
}),
});
const text = await response.text();
let payload = null;
try {
payload = JSON.parse(text);
} catch {
payload = { raw: text };
}
assert(
response.status === 410,
`/api/ai-agent/run legacy guard 应返回退场状态,实际 ${response.status}: ${text.slice(0, 500)}`,
);
const code = response.headers.get("x-error-code") || payload.code || "";
const owner = response.headers.get("x-mnote-ai-execution-owner") || "";
assert(
code === "legacy_ai_agent_run_retired" && owner.includes("retired"),
`legacy guard 不应静默 fallbackcode=${code}, owner=${owner}, body=${text.slice(0, 500)}`,
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
status: response.status,
code,
owner,
},
null,
2,
),
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+176
View File
@@ -0,0 +1,176 @@
#!/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,
} = require("./tree-shell-smoke-helpers");
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-HERMES-AI-smoke-${suffix}`;
const sessionId = `mnote_smoke_${suffix}`;
const runId = `run_smoke_${suffix}`;
let sessionDetailHits = 0;
const captured = [];
const createdIds = [];
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
try {
await page.route("**/api/ai-agent/run", async (route) => {
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
});
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,
title: "当前页问答",
traceId: "trace_smoke",
persistence: "hermes_on_first_run",
}),
});
});
await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
sessionDetailHits += 1;
captured.push({ kind: "session-detail", method: route.request().method(), body: "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
traceId: "trace_restore",
session: {
sessionId,
messages: [
{ role: "user", content: `请总结 ${title}` },
{ role: "tool", content: "mnote.page.get" },
{ role: "assistant", content: "Smoke restored from Hermes session" },
],
},
}),
});
});
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,
runId,
events: [],
traceId: "trace_smoke",
}),
});
});
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
captured.push({ kind: "events", method: route.request().method(), body: "" });
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` +
`data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Smoke " })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Smoke response" })}\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);
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-input]").fill(`请总结 ${title}`, { 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("Smoke response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const sessionRequest = captured.find((entry) => entry.kind === "session");
const runRequest = captured.find((entry) => entry.kind === "run");
const eventRequest = captured.find((entry) => entry.kind === "events");
assert(sessionRequest, "未捕获 /api/hermes/client/sessions 请求");
assert(runRequest, "未捕获 /api/hermes/client/runs 请求");
assert(eventRequest, "未捕获 /api/hermes/client/events 请求");
const runBody = JSON.parse(runRequest.body);
assert.equal(runBody.sessionId, sessionId, "run 请求必须携带 Hermes sessionId");
assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId");
assert(runBody.pageContext, "run 请求必须携带 pageContext");
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Smoke restored from Hermes session"),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(sessionDetailHits > 0, "刷新后必须从 Hermes session detail 恢复消息,而不是从 mnote 本地消息数组恢复");
const persisted = await page.evaluate(() => {
const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:"));
return keys.map((key) => ({ key, value: window.localStorage.getItem(key) }));
});
assert(
persisted.every((entry) => !entry.value || !entry.value.includes("Smoke response")),
"mnote localStorage 不应保存完整聊天消息内容",
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
sessionId,
runId,
capturedKinds: captured.map((entry) => entry.kind),
sessionDetailHits,
},
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);
});
@@ -0,0 +1,239 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { chromium } = require("playwright");
const {
BASE_URL,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openSectionView,
renameDocument,
requestJson,
UI_TIMEOUT_MS,
} = require("./tree-shell-smoke-helpers");
async function main() {
const suffix = Date.now().toString(36);
const originalTitle = `TEST-HERMES-AI-title-original-${suffix}`;
const nextTitle = `TEST-HERMES-AI-title-updated-${suffix}`;
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 ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
await renameDocument(context.request, target.workspaceId, target.documentId, originalTitle);
const common = {
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `mnote_smoke_title_${suffix}`,
runId: `run_smoke_title_${suffix}`,
traceId: `trace_smoke_title_${suffix}`,
capabilityScope: ["page.write"],
dryRun: false,
};
const dryRunTitle = `TEST-HERMES-AI-title-dry-${suffix}`;
const titleDryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
...common,
dryRun: true,
toolName: "mnote.page.update_title",
toolCallId: `call_title_dry_${suffix}`,
idempotencyKey: `idem_title_dry_${suffix}`,
args: { title: dryRunTitle },
},
});
assert.equal(titleDryRun.result.dryRun, true, "标题 dryRun 不应写入");
const metaAfterDryRun = await requestJson(
context.request,
`/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
{ method: "GET" },
);
assert(!JSON.stringify(metaAfterDryRun).includes(dryRunTitle), "标题 dryRun 后 meta 不应变化");
const titleResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
...common,
toolName: "mnote.page.update_title",
toolCallId: `call_title_${suffix}`,
idempotencyKey: `idem_title_${suffix}`,
args: { title: nextTitle },
},
});
assert.equal(titleResult.result.commandName, "page.head.updateTitle", "标题必须走 page.head.updateTitle");
assert.equal(titleResult.audit.commandId, titleResult.result.commandId, "标题 audit commandId 必须指向 Rust commandId");
const titleRetry = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
...common,
toolName: "mnote.page.update_title",
toolCallId: `call_title_${suffix}`,
idempotencyKey: `idem_title_${suffix}`,
args: { title: nextTitle },
},
});
assert.equal(
titleRetry.result.commandId,
titleResult.result.commandId,
"标题同一 idempotencyKey 重试必须返回同一个 commandId",
);
const optionsResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
...common,
toolName: "mnote.page.update_options",
toolCallId: `call_options_${suffix}`,
idempotencyKey: `idem_options_${suffix}`,
args: { options: { wideLayout: true, smallText: true, pageFont: "serif" } },
},
});
assert.equal(
optionsResult.result.commandName,
"page.layout.updateOptions",
"页面设置必须走 page.layout.updateOptions",
);
assert.equal(
optionsResult.audit.commandId,
optionsResult.result.commandId,
"页面设置 audit commandId 必须指向 Rust commandId",
);
assert(
Array.isArray(optionsResult.result.ignoredOptions) &&
optionsResult.result.ignoredOptions.includes("pageFont"),
"planned/ui_only 页面设置字段必须明确返回 ignoredOptions",
);
assert(
Array.isArray(optionsResult.result.warnings) &&
optionsResult.result.warnings.some((warning) => warning && warning.code === "page_option_not_wired"),
"planned/ui_only 页面设置字段必须明确返回 warning",
);
const meta = await requestJson(
context.request,
`/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
{ method: "GET" },
);
assert(JSON.stringify(meta).includes(nextTitle), "meta 未读到 AI 更新后的标题");
assert(JSON.stringify(meta).includes("wide"), "meta 未读到页面设置更新结果");
await openDocument(page, target.workspaceId, target.documentId);
await openSectionView(page);
await page.waitForFunction(
(title) => {
const input = document.querySelector('[data-page-title-input="true"]');
const current = document.querySelector('[data-page-title-current="true"]');
return (input && input.value === title) || (current && current.textContent.includes(title));
},
nextTitle,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
({ documentId, title }) => {
const selectors = [
`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`,
`.tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"] > .tree-link > .tree-link-title`,
];
return selectors.some((selector) => {
const node = document.querySelector(selector);
return node && (node.textContent || "").includes(title);
});
},
{ documentId: target.documentId, title: nextTitle },
{ timeout: UI_TIMEOUT_MS },
);
const uiState = await page.evaluate((documentId) => {
const shell = document.querySelector(".document-shell");
const sessionKey = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || "";
return {
titleInput: document.querySelector('[data-page-title-input="true"]')?.value || "",
currentTitle: document.querySelector('[data-page-title-current="true"]')?.textContent || "",
sidebarTitle:
document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`)
?.textContent || "",
wideLayout: shell?.getAttribute("data-page-wide-layout") || "",
smallText: shell?.getAttribute("data-page-small-text") || "",
pageAiSessionOwner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "",
pageAiSessionKey: sessionKey,
pageAiSessionStorage: sessionKey && window.localStorage ? window.localStorage.getItem(sessionKey) || "" : "",
};
}, target.documentId);
assert(
uiState.titleInput === nextTitle || uiState.currentTitle.includes(nextTitle),
`刷新后页面 UI 未显示 AI 更新标题: ${JSON.stringify(uiState)}`,
);
assert(uiState.sidebarTitle.includes(nextTitle), `sidebar 未显示 AI 更新标题: ${JSON.stringify(uiState)}`);
assert.equal(uiState.wideLayout, "true", "刷新后 document-shell 未应用 wideLayout");
assert.equal(uiState.smallText, "true", "刷新后 document-shell 未应用 smallText");
await page.locator('[data-testid="wolai-floating-ai"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() =>
document.documentElement.getAttribute("data-mnote-page-ai-session-owner") === "hermes" &&
Boolean(document.documentElement.getAttribute("data-mnote-page-ai-session-key")),
undefined,
{ timeout: UI_TIMEOUT_MS },
);
const aiSessionState = await page.evaluate(() => {
const key = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || "";
return {
owner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "",
key,
stored: key && window.localStorage ? window.localStorage.getItem(key) || "" : "",
pageTitle:
document.querySelector('[data-page-title-input="true"]')?.value ||
document.querySelector('[data-page-title-current="true"]')?.textContent ||
"",
};
});
assert.equal(aiSessionState.owner, "hermes", "页面 AI session owner 必须是 Hermes");
const storedSessionState = JSON.parse(aiSessionState.stored || "{}");
assert(storedSessionState.activeSessionId, "mnote 本地只应保存 Hermes activeSessionId");
assert(!aiSessionState.stored.includes(nextTitle), "mnote 本地 AI session 状态不应保存或驱动页面标题真相");
assert(aiSessionState.pageTitle.includes(nextTitle), "页面标题真相必须仍来自页面 UI / Page Aggregate");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
title: nextTitle,
titleCommand: titleResult.result.commandName,
optionsCommand: optionsResult.result.commandName,
ignoredOptions: optionsResult.result.ignoredOptions,
warningCodes: optionsResult.result.warnings.map((warning) => warning.code),
uiState,
aiSessionState,
},
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);
});
+91
View File
@@ -0,0 +1,91 @@
#!/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 main() {
const suffix = Date.now().toString(36);
const title = `TEST-HERMES-AI-tool-${suffix}`;
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 ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", {
method: "GET",
headers: { "x-mnote-actor-id": "smoke-user" },
});
const tools = manifest?.manifest?.tools || [];
assert(tools.some((tool) => tool.name === "mnote.page.get"), "manifest 缺少 mnote.page.get");
const call = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
toolName: "mnote.page.get",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `mnote_smoke_tool_${suffix}`,
runId: `run_smoke_tool_${suffix}`,
toolCallId: `call_smoke_tool_${suffix}`,
traceId: `trace_smoke_tool_${suffix}`,
capabilityScope: ["page.read"],
args: {
includeBody: true,
includeOptions: true,
includeBlocks: true,
},
},
});
assert.equal(call.ok, true, "tool call 应成功");
assert.equal(call.toolName, "mnote.page.get", "toolName 不一致");
assert.equal(call.toolCallId, `call_smoke_tool_${suffix}`, "toolCallId 不一致");
assert.equal(call.result.documentId, target.documentId, "工具结果 documentId 不一致");
assert.equal(call.result.workspaceId, target.workspaceId, "工具结果 workspaceId 不一致");
assert(call.result.title, "工具结果缺少标题");
assert(call.audit && call.audit.effect === "read", "工具结果缺少 read audit");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
toolName: call.toolName,
title: call.result.title,
audit: call.audit,
},
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);
});
@@ -0,0 +1,224 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
async function assertPageAiPermissionFailureUi(page, target, suffix) {
const sessionId = `mnote_smoke_permission_${suffix}`;
const runId = `run_smoke_permission_${suffix}`;
const sessionRoute = "**/api/hermes/client/sessions";
const sessionDetailRoute = `**/api/hermes/client/sessions/${sessionId}`;
const runsRoute = "**/api/hermes/client/runs";
const eventsRoute = `**/api/hermes/client/events/${runId}`;
await page.route(sessionRoute, async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
title: "权限失败 UI 验证",
traceId: `trace_permission_${suffix}`,
}),
});
});
await page.route(sessionDetailRoute, async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId,
session: { sessionId, messages: [] },
}),
});
});
await page.route(runsRoute, async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
upstream: { run_id: runId, status: "started" },
traceId: `trace_permission_${suffix}`,
}),
});
});
await page.route(eventsRoute, async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, tool: "mnote.page.save" })}\n\n` +
`data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, tool: "mnote.page.save", error: { code: "permission_denied" } })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "permission_denied" })}\n\n`,
});
});
try {
await openDocument(page, target.workspaceId, target.documentId);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("权限失败 UI 验证", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return text.includes("mnote.page.save") && text.includes("permission_denied");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
} finally {
await page.unroute(sessionRoute).catch(() => undefined);
await page.unroute(sessionDetailRoute).catch(() => undefined);
await page.unroute(runsRoute).catch(() => undefined);
await page.unroute(eventsRoute).catch(() => undefined);
}
}
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-HERMES-AI-write-${suffix}`;
const marker = `TEST-HERMES-AI-WRITEBACK-${suffix}`;
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 ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const baseTool = {
toolName: "mnote.page.save",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `mnote_smoke_write_${suffix}`,
runId: `run_smoke_write_${suffix}`,
toolCallId: `call_smoke_write_${suffix}`,
traceId: `trace_smoke_write_${suffix}`,
idempotencyKey: `idem_smoke_write_${suffix}`,
capabilityScope: ["page.write"],
args: {
mode: "replace",
content: [
{
id: `block_${suffix}`,
type: "paragraph",
content: [{ type: "text", text: marker }],
},
],
},
};
const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "smoke-user",
"x-mnote-workspace-id": "ws_denied",
},
data: JSON.stringify({
...baseTool,
traceId: `trace_smoke_write_denied_${suffix}`,
toolCallId: `call_smoke_write_denied_${suffix}`,
idempotencyKey: `idem_smoke_write_denied_${suffix}`,
}),
});
const deniedText = await deniedResponse.text();
assert.equal(deniedResponse.status(), 403, "workspace 上下文冲突应返回 403");
assert(deniedText.includes("workspace_context_conflict"), "权限失败应返回稳定 workspace_context_conflict");
assert(!deniedText.includes(title), "权限失败响应不应泄露页面标题");
assert(!deniedText.includes(marker), "权限失败响应不应泄露正文内容");
await assertPageAiPermissionFailureUi(page, target, suffix);
const dryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: { ...baseTool, dryRun: true, idempotencyKey: `idem_smoke_write_dry_${suffix}` },
});
assert.equal(dryRun.result.dryRun, true, "dryRun 不应写入");
const dryRunContent = await requestJson(
context.request,
`/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
{ method: "GET" },
);
assert(!JSON.stringify(dryRunContent).includes(marker), "dryRun 后不应读到 AI 写入标记");
await openDocument(page, target.workspaceId, target.documentId);
await page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS })
.catch(() => undefined);
const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: { ...baseTool, dryRun: false },
});
assert.equal(write.ok, true, "写入 tool call 应成功");
assert.equal(write.audit.effect, "write", "写入 audit effect 应为 write");
assert.equal(write.result.commandName, "page.body.save", "写入必须走 page.body.save");
assert(write.result.commandId, "写入结果必须包含 Rust commandId");
assert.equal(write.audit.commandId, write.result.commandId, "audit commandId 必须指向 Rust commandId");
await page.waitForFunction((expected) => (document.body.textContent || "").includes(expected), marker, {
timeout: UI_TIMEOUT_MS,
});
const retry = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: { ...baseTool, dryRun: false },
});
assert.equal(retry.ok, true, "幂等重试 tool call 应成功");
assert.equal(retry.result.commandId, write.result.commandId, "同一 idempotencyKey 重试必须返回同一个 commandId");
assert.equal(retry.audit.commandId, write.audit.commandId, "同一 idempotencyKey 重试必须返回同一个 audit commandId");
const content = await requestJson(
context.request,
`/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
{ method: "GET" },
);
assert(JSON.stringify(content).includes(marker), "刷新读取内容后未找到 AI 写入标记");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
marker,
commandName: write.result.commandName,
audit: write.audit,
},
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);
});
+10
View File
@@ -1,5 +1,15 @@
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task052-ai-tools-runtime-smoke.js",
reason: "旧 /api/ai-agent/run AI tools runtime smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
@@ -1,5 +1,15 @@
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task111-phase7-document-ai-online-smoke.js",
reason: "旧 /api/ai-agent/run phase7 online smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const { chromium } = require("playwright");
const {
BASE_URL,
+10
View File
@@ -1,6 +1,16 @@
#!/usr/bin/env node
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task155-e27-ai-edit-smoke.js",
reason: "旧 /api/ai-agent/run E27 AI 编辑 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
+10
View File
@@ -1,6 +1,16 @@
#!/usr/bin/env node
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task156-e27-ai-writeback-smoke.js",
reason: "旧 /api/ai-agent/run E27 AI 写回 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
@@ -1,6 +1,16 @@
#!/usr/bin/env node
"use strict";
if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
console.log(JSON.stringify({
ok: true,
retired: true,
script: "task178-page-ai-local-subtree-context-smoke.js",
reason: "旧 /api/ai-agent/run 页面 AI context smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
}, null, 2));
process.exit(0);
}
const assert = require("node:assert");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");