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

240 lines
9.9 KiB
JavaScript
Raw Normal View History

2026-05-14 15:10:33 +08:00
#!/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);
});