feat(ai): switch page ai to hermes panel
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user