225 lines
8.6 KiB
JavaScript
225 lines
8.6 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,
|
||
|
|
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);
|
||
|
|
});
|