240 lines
9.9 KiB
JavaScript
240 lines
9.9 KiB
JavaScript
#!/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);
|
|
});
|