Files
mnote/scripts/task-page-aggregate-refresh-persistence-smoke.js
T

523 lines
22 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
async function callMnoteTool(requestContext, payload) {
return requestJson(requestContext, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
const payload = await requestJson(
requestContext,
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
return payload.result;
}
function pageSubtreeSummary(aggregate) {
const subtree = aggregate?.tree?.pageSubtree ?? null;
return {
rootNodeId: subtree?.rootNodeId ?? null,
outlineLength: Array.isArray(subtree?.outline) ? subtree.outline.length : null,
};
}
async function fetchAiDoc(requestContext, target, actorId, suffix, label) {
const response = await callMnoteTool(requestContext, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_page_aggregate_ai_fetch_${suffix}`,
runId: `run_page_aggregate_ai_fetch_${suffix}_${label}`,
toolCallId: `call_page_aggregate_ai_fetch_${suffix}_${label}`,
traceId: `trace_page_aggregate_ai_fetch_${suffix}_${label}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 20,
},
});
assert.equal(response.ok, true, `${label}: mnote.doc.fetch 应成功`);
assert.equal(response.result.schema, "mnote.page_ai_context.v1", `${label}: AI fetch schema 应稳定`);
assert(Array.isArray(response.result.blocks), `${label}: AI fetch 应返回 blocks`);
return response.result;
}
function findBlockWithText(aggregate, text) {
const blocks = aggregate?.body?.blockDocument?.blocks;
if (!Array.isArray(blocks)) return null;
return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null;
}
async function waitForRuntimeIsland(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPageTitleInput(page) {
const input = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input;
}
async function readVisibleTitle(page) {
const titleInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
if (await titleInput.isVisible().catch(() => false)) {
return (await titleInput.inputValue()).trim();
}
const heading = page.locator("h1").first();
return ((await heading.textContent()) ?? "").trim();
}
async function readEditorText(page) {
return page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").trim();
});
}
async function renameThroughPageHead(page, documentId, title) {
const titleInput = await waitForPageTitleInput(page);
const responsePromise = page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return (
payload?.documentId === documentId &&
payload?.title === title &&
payload?.commandName === "page.head.updateTitle" &&
response.ok()
);
},
{ timeout: UI_TIMEOUT_MS },
);
await titleInput.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.type(title, { delay: 10 });
await titleInput.blur();
const response = await responsePromise;
return response.status();
}
async function typeIntoEditor(page, documentId, text) {
const responsePromise = page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return payload?.documentId === documentId && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.press("Backspace");
await page.keyboard.type(text, { delay: 10 });
const response = await responsePromise;
return response.status();
}
async function openPageSettingsDialog(page) {
const trigger = page.getByTestId("wolai-page-settings-trigger");
await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await trigger.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForOptionsResponse(page, documentId, mutate) {
const responsePromise = page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/ui/preferences") || response.request().method() !== "PUT") {
return false;
}
const payload = response.request().postDataJSON();
return payload?.documentId === documentId && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
await mutate();
const response = await responsePromise;
const payload = await response.json();
assert.equal(payload?.owner, "mnote-web", "页面设置写入必须由 mnote-web 持有");
assert(payload?.result?.pageOptions, "页面设置写入必须返回 SQLite 合并后的 pageOptions");
return response.status();
}
async function setPageOptionsThroughUi(page, documentId) {
await openPageSettingsDialog(page);
const wideStatus = await waitForOptionsResponse(page, documentId, async () => {
await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS });
});
const smallStatus = await waitForOptionsResponse(page, documentId, async () => {
await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS });
});
const densityStatus = await waitForOptionsResponse(page, documentId, async () => {
await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact");
});
return { wideStatus, smallStatus, densityStatus };
}
async function readRuntimePageOptions(page) {
return page.evaluate(() => {
const shell = document.querySelector(".document-shell");
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorSurface = editorRoot?.querySelector(".editor-surface");
const editor = editorRoot?.querySelector(".ProseMirror");
const firstParagraph = editorRoot?.querySelector(".ProseMirror p");
return {
htmlWide: document.documentElement.getAttribute("data-page-wide-layout"),
htmlSmall: document.documentElement.getAttribute("data-page-small-text"),
htmlDensity: document.documentElement.getAttribute("data-layout-density"),
shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null,
shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null,
shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null,
editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null,
editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null,
editorSurfaceDensity:
editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null,
editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null,
paragraphMarginBottom:
firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null,
};
});
}
async function readPageSettingsControls(page) {
await openPageSettingsDialog(page);
return {
wideLayout: await page.locator('[data-page-option-checkbox="wideLayout"]').isChecked(),
smallText: await page.locator('[data-page-option-checkbox="smallText"]').isChecked(),
layoutDensity: await page.locator('[data-page-option-select="layoutDensity"]').inputValue(),
};
}
async function waitForRuntimePageOptions(page) {
try {
await page.waitForFunction(
() => {
const shell = document.querySelector(".document-shell");
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorSurface = editorRoot?.querySelector(".editor-surface");
return (
document.documentElement.getAttribute("data-page-wide-layout") === "true" &&
document.documentElement.getAttribute("data-page-small-text") === "true" &&
document.documentElement.getAttribute("data-layout-density") === "compact" &&
shell?.getAttribute("data-page-wide-layout") === "true" &&
shell?.getAttribute("data-page-small-text") === "true" &&
shell?.getAttribute("data-layout-density") === "compact" &&
editorRoot?.getAttribute("data-page-wide-layout") === "true" &&
editorRoot?.getAttribute("data-page-small-text") === "true" &&
editorSurface?.getAttribute("data-layout-density") === "compact"
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
} catch (error) {
const runtime = await readRuntimePageOptions(page).catch((runtimeError) => ({
readRuntimeError: runtimeError instanceof Error ? runtimeError.message : String(runtimeError),
}));
const embedded = await page.evaluate(() => {
const node = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
try {
return node?.textContent ? JSON.parse(node.textContent) : null;
} catch (parseError) {
return { parseError: parseError instanceof Error ? parseError.message : String(parseError) };
}
}).catch((embeddedError) => ({
readEmbeddedError: embeddedError instanceof Error ? embeddedError.message : String(embeddedError),
}));
throw new Error(
`等待 runtime page options 同步超时:${JSON.stringify({ runtime, embeddedPageOptions: embedded?.layout?.pageOptions ?? null })}\n${
error instanceof Error ? error.stack || error.message : String(error)
}`,
);
}
}
async function waitForAggregateState(requestContext, workspaceId, documentId, expectedTitle, expectedText) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let aggregate = null;
while (Date.now() < deadline) {
aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
const options = aggregate?.layout?.pageOptions ?? {};
const matchedBlock = findBlockWithText(aggregate, expectedText);
if (
aggregate?.head?.title === expectedTitle &&
matchedBlock &&
options.wideLayout === true &&
options.smallText === true &&
options.layoutDensity === "compact"
) {
return { aggregate, matchedBlock };
}
await new Promise((resolve) => setTimeout(resolve, 350));
}
throw new Error(
`等待 Page Aggregate 刷新持久态同步超时:${JSON.stringify({
documentId,
expectedTitle,
expectedText,
lastTitle: aggregate?.head?.title ?? null,
lastOptions: aggregate?.layout?.pageOptions ?? null,
hasExpectedBlock: Boolean(aggregate && findBlockWithText(aggregate, expectedText)),
})}`,
);
}
function assertRuntimeOptions(runtime, label) {
assert.equal(runtime.htmlWide, "true", `${label} html wideLayout 应保持 true`);
assert.equal(runtime.htmlSmall, "true", `${label} html smallText 应保持 true`);
assert.equal(runtime.htmlDensity, "compact", `${label} html layoutDensity 应保持 compact`);
assert.equal(runtime.editorRootWide, "true", `${label} island root wideLayout 应保持 true`);
assert.equal(runtime.editorRootSmall, "true", `${label} island root smallText 应保持 true`);
assert.equal(runtime.editorSurfaceDensity, "compact", `${label} island surface density 应保持 compact`);
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const title = `Page Aggregate refresh ${suffix}`;
const bodyText = `Page Aggregate refresh body ${suffix}`;
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
const browser = await chromium.launch({
headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const createdIds = [];
try {
const viewer = await ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request, null);
createdIds.push(target.documentId);
await openDocument(page, target.workspaceId, target.documentId);
await waitForPageTitleInput(page);
await waitForRuntimeIsland(page);
const titleStatus = await renameThroughPageHead(page, target.documentId, title);
assert((await readVisibleTitle(page)).includes(title), "写入后页头标题应立即显示最新值");
const saveStatus = await typeIntoEditor(page, target.documentId, bodyText);
await page.waitForFunction(
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
bodyText,
{ timeout: UI_TIMEOUT_MS },
);
const optionStatuses = await setPageOptionsThroughUi(page, target.documentId);
await waitForRuntimePageOptions(page);
const beforeReloadRuntime = await readRuntimePageOptions(page);
assertRuntimeOptions(beforeReloadRuntime, "刷新前");
const { aggregate: beforeReloadAggregate, matchedBlock: beforeReloadBlock } = await waitForAggregateState(
context.request,
target.workspaceId,
target.documentId,
title,
bodyText,
);
const beforeReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "before_reload");
const beforeReloadAiBlock = beforeReloadAiFetch.blocks.find((block) => block.blockId === beforeReloadBlock.blockId);
assert.equal(beforeReloadAiFetch.revision, beforeReloadAggregate.body?.revision, "刷新前 AI fetch revision 应与 Page Aggregate 一致");
assert.equal(
beforeReloadAiFetch.conflictDetectionKey,
beforeReloadAggregate.body?.conflictDetectionKey,
"刷新前 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
);
assert.equal(beforeReloadAiBlock?.text, bodyText, "刷新前 AI fetch 应回读 Page Aggregate 正文块");
assert.equal(
beforeReloadAggregate.tree?.pageSubtree?.rootNodeId,
target.documentId,
"刷新前 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForPageTitleInput(page);
await waitForRuntimeIsland(page);
await page.waitForFunction(
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
bodyText,
{ timeout: UI_TIMEOUT_MS },
);
await waitForRuntimePageOptions(page);
const afterReloadTitle = await readVisibleTitle(page);
const afterReloadEditorText = await readEditorText(page);
const afterReloadRuntime = await readRuntimePageOptions(page);
assert(afterReloadTitle.includes(title), "刷新后页头标题不应回退旧快照");
assert(afterReloadEditorText.includes(bodyText), "刷新后正文不应回退旧快照");
assertRuntimeOptions(afterReloadRuntime, "刷新后");
const afterReloadControls = await readPageSettingsControls(page);
assert.equal(afterReloadControls.wideLayout, true, "刷新后页面设置 wideLayout 控件应保持 true");
assert.equal(afterReloadControls.smallText, true, "刷新后页面设置 smallText 控件应保持 true");
assert.equal(afterReloadControls.layoutDensity, "compact", "刷新后页面设置 layoutDensity 控件应保持 compact");
const { aggregate: afterReloadAggregate, matchedBlock: afterReloadBlock } = await waitForAggregateState(
context.request,
target.workspaceId,
target.documentId,
title,
bodyText,
);
const afterReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "after_reload");
const afterReloadAiBlock = afterReloadAiFetch.blocks.find((block) => block.blockId === afterReloadBlock.blockId);
assert.equal(afterReloadAiFetch.revision, afterReloadAggregate.body?.revision, "刷新后 AI fetch revision 应与 Page Aggregate 一致");
assert.equal(
afterReloadAiFetch.conflictDetectionKey,
afterReloadAggregate.body?.conflictDetectionKey,
"刷新后 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
);
assert.equal(afterReloadAiBlock?.text, bodyText, "刷新后 AI fetch 应回读 Page Aggregate 正文块");
assert.equal(
afterReloadAggregate.tree?.pageSubtree?.rootNodeId,
target.documentId,
"刷新后 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
);
await page.screenshot({ path: screenshotPath, fullPage: false });
const evidence = {
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: target.workspaceId,
documentId: target.documentId,
expected: {
title,
bodyText,
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
commandStatuses: {
titleStatus,
saveStatus,
...optionStatuses,
},
beforeReload: {
aggregate: {
title: beforeReloadAggregate.head?.title ?? null,
revision: beforeReloadAggregate.body?.revision ?? null,
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
pageSubtree: pageSubtreeSummary(beforeReloadAggregate),
},
matchedBlock: {
blockId: beforeReloadBlock.blockId,
text: beforeReloadBlock.text,
revisionRef: beforeReloadBlock.revisionRef,
},
aiFetch: {
schema: beforeReloadAiFetch.schema,
revision: beforeReloadAiFetch.revision,
conflictDetectionKey: beforeReloadAiFetch.conflictDetectionKey,
matchedBlock: {
blockId: beforeReloadAiBlock?.blockId ?? null,
text: beforeReloadAiBlock?.text ?? null,
revisionRef: beforeReloadAiBlock?.revisionRef ?? null,
},
},
runtime: beforeReloadRuntime,
},
afterReload: {
aggregate: {
title: afterReloadAggregate.head?.title ?? null,
revision: afterReloadAggregate.body?.revision ?? null,
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
pageSubtree: pageSubtreeSummary(afterReloadAggregate),
},
matchedBlock: {
blockId: afterReloadBlock.blockId,
text: afterReloadBlock.text,
revisionRef: afterReloadBlock.revisionRef,
},
aiFetch: {
schema: afterReloadAiFetch.schema,
revision: afterReloadAiFetch.revision,
conflictDetectionKey: afterReloadAiFetch.conflictDetectionKey,
matchedBlock: {
blockId: afterReloadAiBlock?.blockId ?? null,
text: afterReloadAiBlock?.text ?? null,
revisionRef: afterReloadAiBlock?.revisionRef ?? null,
},
},
title: afterReloadTitle,
editorText: afterReloadEditorText,
runtime: afterReloadRuntime,
controls: afterReloadControls,
},
screenshotPath,
evidencePath,
};
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
console.log(JSON.stringify(evidence, null, 2));
} finally {
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
await page.close().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);
});