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