415 lines
16 KiB
JavaScript
415 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const fs = require("node:fs/promises");
|
|
const path = require("node:path");
|
|
const { chromium } = require("playwright");
|
|
const {
|
|
BASE_URL,
|
|
UI_TIMEOUT_MS,
|
|
assert,
|
|
cleanupDocuments,
|
|
createTempDocument,
|
|
ensureAuthenticated,
|
|
openDocument,
|
|
renameDocument,
|
|
} = require("./tree-shell-smoke-helpers");
|
|
|
|
const TASK = "task168-mindmap-put-validator-smoke";
|
|
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
|
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
|
const BAD_TEXT_PATTERN = /ArgumentValidationError|domainEventHint|domainEventPlan|streamDeltaHint|command_failed|502 Bad Gateway/i;
|
|
|
|
async function writeResult(payload) {
|
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
|
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function screenshot(page, name) {
|
|
const file = path.join(OUTPUT_DIR, `${name}.png`);
|
|
await page.screenshot({ path: file, fullPage: true });
|
|
return file;
|
|
}
|
|
|
|
function attachMindmapNetworkCapture(page, label, records) {
|
|
page.on("response", async (response) => {
|
|
const url = response.url();
|
|
if (!url.includes("/api/mindmap/") && !url.includes("/mindmap/") && !url.includes("/api/documents/save")) {
|
|
return;
|
|
}
|
|
const request = response.request();
|
|
const method = request.method();
|
|
const status = response.status();
|
|
let responseText = null;
|
|
if (method !== "GET" || status >= 400) {
|
|
responseText = await response.text().catch((error) => `<<read_response_failed:${error.message}>>`);
|
|
}
|
|
records.push({
|
|
type: "response",
|
|
label,
|
|
method,
|
|
url,
|
|
status,
|
|
statusText: response.statusText(),
|
|
requestBody: request.postData() || null,
|
|
responseText: responseText ? responseText.slice(0, 3000) : null,
|
|
});
|
|
});
|
|
page.on("requestfailed", (request) => {
|
|
const url = request.url();
|
|
if (url.includes("/api/mindmap/") || url.includes("/mindmap/") || url.includes("/api/documents/save")) {
|
|
records.push({
|
|
type: "requestfailed",
|
|
label,
|
|
method: request.method(),
|
|
url,
|
|
failure: request.failure()?.errorText || null,
|
|
requestBody: request.postData() || null,
|
|
});
|
|
}
|
|
});
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") {
|
|
const text = message.text();
|
|
if (/mindmap|ArgumentValidationError|domainEvent|502|command_failed/i.test(text)) {
|
|
records.push({
|
|
type: "console",
|
|
label,
|
|
level: message.type(),
|
|
text: text.slice(0, 2000),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function insertMindmapThroughSlash(page) {
|
|
const editor = page
|
|
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
|
.first();
|
|
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
await editor.click({ timeout: UI_TIMEOUT_MS });
|
|
await page.keyboard.type("/");
|
|
const item = page.getByTestId("slash-item-mindmap").first();
|
|
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
await item.click({ timeout: UI_TIMEOUT_MS });
|
|
}
|
|
|
|
async function readMindmapState(page) {
|
|
return page.evaluate(() => {
|
|
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
|
|
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
|
const runtime = document.querySelector('[data-testid="simple-mind-map-runtime"]');
|
|
const error = document.querySelector('[data-testid="leptos-mindmap-error"]');
|
|
return {
|
|
mindmapId: root instanceof HTMLElement ? root.dataset.mnoteMindmapId || null : null,
|
|
runtimeReady: runtime instanceof HTMLElement ? runtime.dataset.runtimeReady === "true" : false,
|
|
commandStatus: scene instanceof HTMLElement ? scene.dataset.commandStatus || null : null,
|
|
lastSchemaAction: scene instanceof HTMLElement ? scene.dataset.lastSchemaAction || null : null,
|
|
commandRuntimeError: scene instanceof HTMLElement ? scene.dataset.commandRuntimeError || null : null,
|
|
commandRuntimeMessage: scene instanceof HTMLElement ? scene.dataset.commandRuntimeMessage || null : null,
|
|
errorStage: error instanceof HTMLElement ? error.dataset.stage || null : null,
|
|
errorText: error instanceof HTMLElement ? (error.textContent || "").slice(0, 3000) : null,
|
|
bodyText: (document.body?.innerText || "").slice(0, 5000),
|
|
};
|
|
});
|
|
}
|
|
|
|
async function assertNoValidatorLeak(page, records, stage) {
|
|
await page.waitForTimeout(500);
|
|
const state = await readMindmapState(page);
|
|
const badRecord = records.find((record) => {
|
|
if (record.type === "response" && record.status >= 500) return true;
|
|
return BAD_TEXT_PATTERN.test(`${record.responseText || ""}\n${record.text || ""}\n${record.failure || ""}`);
|
|
});
|
|
if (badRecord || BAD_TEXT_PATTERN.test(`${state.errorText || ""}\n${state.bodyText || ""}`)) {
|
|
throw new Error(
|
|
`${stage}:mindmap_validator_or_502_leak:${JSON.stringify(
|
|
{
|
|
state,
|
|
badRecord,
|
|
recentMindmapNetwork: records.slice(-12),
|
|
},
|
|
null,
|
|
2,
|
|
)}`,
|
|
);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
async function waitForMindmapReady(page, stage) {
|
|
await page
|
|
.waitForFunction(
|
|
() => {
|
|
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
|
|
const runtime = document.querySelector('[data-testid="simple-mind-map-runtime"]');
|
|
const mindmapId = root instanceof HTMLElement ? root.dataset.mnoteMindmapId || null : null;
|
|
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
|
return (
|
|
root instanceof HTMLElement &&
|
|
runtime instanceof HTMLElement &&
|
|
runtime.dataset.runtimeReady === "true" &&
|
|
Boolean(mindmapId) &&
|
|
Boolean(registry[mindmapId])
|
|
);
|
|
},
|
|
null,
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
)
|
|
.catch((error) => {
|
|
throw new Error(`${stage}:mindmap_not_ready:${error.message}`);
|
|
});
|
|
}
|
|
|
|
async function waitForDocumentContentToIncludeMindmap(requestContext, documentId, workspaceId, mindmapId) {
|
|
let lastText = "";
|
|
for (let index = 0; index < 45; index += 1) {
|
|
const response = await requestContext.fetch(
|
|
`${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
|
{ method: "GET", timeout: 10_000 },
|
|
);
|
|
lastText = await response.text();
|
|
if (response.ok() && lastText.includes(mindmapId)) {
|
|
try {
|
|
return JSON.parse(lastText);
|
|
} catch {
|
|
return { raw: lastText.slice(0, 3000) };
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
throw new Error(
|
|
`document_content_missing_mindmap:${JSON.stringify({
|
|
documentId,
|
|
workspaceId,
|
|
mindmapId,
|
|
lastText: lastText.slice(0, 5000),
|
|
})}`,
|
|
);
|
|
}
|
|
|
|
async function fetchMindmapProjection(requestContext, documentId, mindmapId) {
|
|
const response = await requestContext.fetch(
|
|
`${BASE_URL}/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`,
|
|
{ method: "GET", timeout: 10_000 },
|
|
);
|
|
const text = await response.text();
|
|
assert(response.ok(), `mindmap_projection_fetch_failed:${response.status()}:${text.slice(0, 2000)}`);
|
|
const payload = JSON.parse(text);
|
|
return payload.result ?? payload;
|
|
}
|
|
|
|
function collectMindmapTexts(root) {
|
|
const texts = [];
|
|
const visit = (node) => {
|
|
if (!node || typeof node !== "object") return;
|
|
const data = node.data && typeof node.data === "object" ? node.data : {};
|
|
if (typeof data.text === "string") texts.push(data.text);
|
|
if (Array.isArray(node.children)) node.children.forEach(visit);
|
|
};
|
|
visit(root);
|
|
return texts;
|
|
}
|
|
|
|
async function waitForProjectionText(requestContext, documentId, mindmapId, expectedText) {
|
|
let lastProjection = null;
|
|
for (let index = 0; index < 45; index += 1) {
|
|
lastProjection = await fetchMindmapProjection(requestContext, documentId, mindmapId);
|
|
const texts = collectMindmapTexts(lastProjection.root);
|
|
if (texts.includes(expectedText)) {
|
|
return { projection: lastProjection, texts };
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
throw new Error(
|
|
`mindmap_projection_missing_edited_text:${JSON.stringify({
|
|
expectedText,
|
|
texts: collectMindmapTexts(lastProjection?.root),
|
|
projection: lastProjection,
|
|
}).slice(0, 5000)}`,
|
|
);
|
|
}
|
|
|
|
async function editTopicTextThroughRuntime(page, mindmapId, text) {
|
|
const result = await page.evaluate(
|
|
({ mindmapId, text }) => {
|
|
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
|
if (scene instanceof HTMLElement) {
|
|
scene.dataset.lastDataChangeRefreshTriggered = "";
|
|
scene.dataset.lastDataChangeDiffCommandCount = "";
|
|
scene.dataset.commandStatus = "smoke-waiting-topic-edit";
|
|
}
|
|
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
|
const bridge = registry[mindmapId];
|
|
const instance = bridge?.instance;
|
|
const topicNode = instance?.renderer?.findNodeByUid?.("topic") || null;
|
|
if (!topicNode || typeof topicNode !== "object") {
|
|
return { ok: false, reason: "topic_node_missing" };
|
|
}
|
|
if (typeof topicNode.setData === "function") {
|
|
topicNode.setData({ text });
|
|
} else if (topicNode.data && typeof topicNode.data === "object") {
|
|
topicNode.data.text = text;
|
|
} else {
|
|
return { ok: false, reason: "topic_node_not_mutable" };
|
|
}
|
|
const snapshot = typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : instance?.getData?.(true);
|
|
return {
|
|
ok: true,
|
|
snapshotText: snapshot?.children?.[0]?.data?.text ?? null,
|
|
commandStatus: scene instanceof HTMLElement ? scene.dataset.commandStatus || null : null,
|
|
diffCommandCount: scene instanceof HTMLElement ? scene.dataset.lastDataChangeDiffCommandCount || null : null,
|
|
};
|
|
},
|
|
{ mindmapId, text },
|
|
);
|
|
assert(result.ok, `topic_runtime_edit_failed:${JSON.stringify(result)}`);
|
|
await page.waitForFunction(
|
|
({ text }) => {
|
|
const bodyText = document.body?.innerText || "";
|
|
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
|
return (
|
|
bodyText.includes(text) ||
|
|
(scene instanceof HTMLElement && scene.dataset.commandStatus !== "smoke-waiting-topic-edit")
|
|
);
|
|
},
|
|
{ text },
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
);
|
|
return result;
|
|
}
|
|
|
|
async function clickInsertChild(page) {
|
|
const button = page.getByTestId("mindmap-schema-toolbar-action-insertChild");
|
|
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
const disabled = await button.evaluate((node) => node instanceof HTMLButtonElement && node.disabled);
|
|
if (disabled) {
|
|
await page.locator('[data-testid="simple-mind-map-runtime"] .smm-node').first().click({ timeout: UI_TIMEOUT_MS });
|
|
await page.waitForFunction(() => {
|
|
const button = document.querySelector('[data-testid="mindmap-schema-toolbar-action-insertChild"]');
|
|
return button instanceof HTMLButtonElement && !button.disabled;
|
|
}, null, { timeout: UI_TIMEOUT_MS });
|
|
}
|
|
await page.evaluate(() => {
|
|
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
|
if (scene instanceof HTMLElement) {
|
|
scene.dataset.commandStatus = "smoke-waiting";
|
|
delete scene.dataset.lastSchemaAction;
|
|
}
|
|
});
|
|
await button.click({ timeout: UI_TIMEOUT_MS });
|
|
await page.waitForFunction(() => {
|
|
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
|
return scene instanceof HTMLElement && scene.dataset.lastSchemaAction === "insertChild" && scene.dataset.commandStatus === "success";
|
|
}, null, { timeout: UI_TIMEOUT_MS });
|
|
}
|
|
|
|
async function main() {
|
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const contextA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
|
const pageA = await contextA.newPage();
|
|
const records = [];
|
|
attachMindmapNetworkCapture(pageA, "browser-a", records);
|
|
const screenshots = [];
|
|
const createdIds = [];
|
|
const result = {
|
|
ok: false,
|
|
task: TASK,
|
|
baseUrl: BASE_URL,
|
|
documentId: null,
|
|
workspaceId: null,
|
|
mindmapId: null,
|
|
network: records,
|
|
screenshots,
|
|
};
|
|
|
|
let contextB = null;
|
|
let pageB = null;
|
|
try {
|
|
await ensureAuthenticated(pageA, contextA.request);
|
|
const doc = await createTempDocument(contextA.request, null);
|
|
createdIds.push(doc.documentId);
|
|
result.documentId = doc.documentId;
|
|
result.workspaceId = doc.workspaceId;
|
|
await renameDocument(contextA.request, doc.workspaceId, doc.documentId, `task168-mindmap-${Date.now().toString().slice(-6)}`);
|
|
|
|
await openDocument(pageA, doc.workspaceId, doc.documentId);
|
|
await insertMindmapThroughSlash(pageA);
|
|
await waitForMindmapReady(pageA, "browser-a-initial");
|
|
const initialState = await assertNoValidatorLeak(pageA, records, "browser-a-initial");
|
|
result.mindmapId = initialState.mindmapId;
|
|
|
|
await clickInsertChild(pageA);
|
|
const afterCommand = await assertNoValidatorLeak(pageA, records, "browser-a-insert-child");
|
|
assert(afterCommand.commandStatus === "success", `insert_child_not_success:${JSON.stringify(afterCommand)}`);
|
|
screenshots.push(await screenshot(pageA, "01-after-insert-child"));
|
|
result.documentContentAfterInsert = await waitForDocumentContentToIncludeMindmap(
|
|
contextA.request,
|
|
doc.documentId,
|
|
doc.workspaceId,
|
|
result.mindmapId,
|
|
);
|
|
|
|
const editedTopicText = `二级节点-SMOKE-${Date.now().toString().slice(-6)}`;
|
|
result.topicEditAttempt = await editTopicTextThroughRuntime(pageA, result.mindmapId, editedTopicText);
|
|
const afterTopicEdit = await assertNoValidatorLeak(pageA, records, "browser-a-topic-edit");
|
|
result.afterTopicEdit = afterTopicEdit;
|
|
result.topicProjectionAfterEdit = await waitForProjectionText(
|
|
contextA.request,
|
|
doc.documentId,
|
|
result.mindmapId,
|
|
editedTopicText,
|
|
);
|
|
screenshots.push(await screenshot(pageA, "02-after-topic-edit"));
|
|
|
|
contextB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
|
pageB = await contextB.newPage();
|
|
attachMindmapNetworkCapture(pageB, "browser-b", records);
|
|
await ensureAuthenticated(pageB, contextB.request);
|
|
await openDocument(pageB, doc.workspaceId, doc.documentId);
|
|
await waitForMindmapReady(pageB, "browser-b-reopen");
|
|
const secondState = await assertNoValidatorLeak(pageB, records, "browser-b-reopen");
|
|
assert(
|
|
secondState.bodyText.includes(editedTopicText),
|
|
`second_browser_missing_edited_topic_text:${JSON.stringify({
|
|
expected: editedTopicText,
|
|
secondState,
|
|
})}`,
|
|
);
|
|
assert(
|
|
secondState.mindmapId === result.mindmapId,
|
|
`second_browser_mindmap_id_mismatch:${JSON.stringify({ expected: result.mindmapId, actual: secondState.mindmapId })}`,
|
|
);
|
|
screenshots.push(await screenshot(pageB, "03-second-browser-visible"));
|
|
|
|
result.ok = true;
|
|
result.initialState = initialState;
|
|
result.afterCommand = afterCommand;
|
|
result.secondState = secondState;
|
|
await writeResult(result);
|
|
} catch (error) {
|
|
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
|
screenshots.push(await screenshot(pageA, "99-failure").catch(() => null));
|
|
if (pageB) {
|
|
screenshots.push(await screenshot(pageB, "99-failure-browser-b").catch(() => null));
|
|
}
|
|
await writeResult(result);
|
|
throw error;
|
|
} finally {
|
|
if (contextB) {
|
|
await contextB.close().catch(() => undefined);
|
|
}
|
|
await cleanupDocuments(contextA.request, createdIds).catch(() => undefined);
|
|
await contextA.close().catch(() => undefined);
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|