371 lines
15 KiB
JavaScript
371 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
"use strict";
|
||
|
|
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const fs = require("node:fs/promises");
|
||
|
|
const path = require("node:path");
|
||
|
|
const { chromium } = require("playwright");
|
||
|
|
|
||
|
|
const TASK = "task448-tree-resync-recovery-dual-browser-smoke";
|
||
|
|
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||
|
|
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||
|
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 40_000);
|
||
|
|
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task448-resync");
|
||
|
|
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||
|
|
const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-resync.png");
|
||
|
|
const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-resync.png");
|
||
|
|
|
||
|
|
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||
|
|
|
||
|
|
function cssString(value) {
|
||
|
|
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||
|
|
}
|
||
|
|
|
||
|
|
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 requestJson(request, baseUrl, requestPath, init = {}) {
|
||
|
|
const response = await request.fetch(`${baseUrl}${requestPath}`, {
|
||
|
|
...init,
|
||
|
|
headers: {
|
||
|
|
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||
|
|
...(init.headers || {}),
|
||
|
|
},
|
||
|
|
timeout: 20_000,
|
||
|
|
});
|
||
|
|
const text = await response.text();
|
||
|
|
let payload = null;
|
||
|
|
try {
|
||
|
|
payload = text ? JSON.parse(text) : null;
|
||
|
|
} catch {
|
||
|
|
payload = text;
|
||
|
|
}
|
||
|
|
if (!response.ok()) {
|
||
|
|
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||
|
|
}
|
||
|
|
return payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function authenticate(request, email, name) {
|
||
|
|
await requestJson(request, AUTH_BASE_URL, "/api/auth", {
|
||
|
|
method: "POST",
|
||
|
|
data: {
|
||
|
|
action: "auth:signIn",
|
||
|
|
args: {
|
||
|
|
provider: "password",
|
||
|
|
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createPage(request, workspaceId, title, parentId = null) {
|
||
|
|
const payload = await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||
|
|
method: "POST",
|
||
|
|
data: { action: "create", workspaceId, parentId, title },
|
||
|
|
});
|
||
|
|
const result = payload.result || payload;
|
||
|
|
const documentId = result.documentId || payload.documentId || result.id || "";
|
||
|
|
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||
|
|
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||
|
|
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||
|
|
return { documentId, workspaceId: resolvedWorkspaceId, title, payload };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function treeCommand(request, workspaceId, action, documentId, extra = {}) {
|
||
|
|
return await requestJson(request, BASE_URL, "/api/tree/commands", {
|
||
|
|
method: "POST",
|
||
|
|
data: { action, workspaceId, documentId, ...extra },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function emptyDocumentTrash(request, workspaceId) {
|
||
|
|
return await requestJson(request, BASE_URL, "/api/documents/empty-trash", {
|
||
|
|
method: "POST",
|
||
|
|
data: { workspaceId },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function cleanup(request, workspaceId, documentIds) {
|
||
|
|
if (!workspaceId) return;
|
||
|
|
for (const documentId of documentIds.filter(Boolean)) {
|
||
|
|
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||
|
|
}
|
||
|
|
await emptyDocumentTrash(request, workspaceId).catch(() => null);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function installSlowTreeEventSource(page, pollMs) {
|
||
|
|
await page.addInitScript((value) => {
|
||
|
|
const OriginalEventSource = window.EventSource;
|
||
|
|
if (typeof OriginalEventSource !== "function" || window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__) return;
|
||
|
|
window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__ = true;
|
||
|
|
window.EventSource = function patchedEventSource(input, init) {
|
||
|
|
try {
|
||
|
|
const url = new URL(String(input), window.location.href);
|
||
|
|
if (url.pathname === "/api/tree/events") {
|
||
|
|
url.searchParams.set("pollMs", String(value));
|
||
|
|
return new OriginalEventSource(url.toString(), init);
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
}
|
||
|
|
return new OriginalEventSource(input, init);
|
||
|
|
};
|
||
|
|
window.EventSource.prototype = OriginalEventSource.prototype;
|
||
|
|
}, pollMs);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function installTreeEventRecorder(page, label, requests) {
|
||
|
|
await page.addInitScript(() => {
|
||
|
|
window.__MNOTE_TASK448_TREE_EVENTS__ = [];
|
||
|
|
const record = (name, event) => {
|
||
|
|
const detail = event && event.detail ? event.detail : {};
|
||
|
|
const payload = detail.payload || detail || {};
|
||
|
|
let raw = "";
|
||
|
|
try {
|
||
|
|
raw = JSON.stringify(payload);
|
||
|
|
} catch {
|
||
|
|
raw = "";
|
||
|
|
}
|
||
|
|
window.__MNOTE_TASK448_TREE_EVENTS__.push({
|
||
|
|
name,
|
||
|
|
at: Date.now(),
|
||
|
|
revision: detail.revision || payload.revision || payload.cursor || "",
|
||
|
|
kind: payload.kind || "",
|
||
|
|
op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "",
|
||
|
|
raw: raw.slice(0, 2400),
|
||
|
|
});
|
||
|
|
};
|
||
|
|
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||
|
|
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||
|
|
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||
|
|
});
|
||
|
|
page.on("request", (request) => {
|
||
|
|
const url = request.url();
|
||
|
|
if (url.includes("/api/tree/events")) {
|
||
|
|
requests.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
page.on("requestfailed", (request) => {
|
||
|
|
const url = request.url();
|
||
|
|
if (url.includes("/api/tree/events")) {
|
||
|
|
requests.push({
|
||
|
|
label,
|
||
|
|
type: "requestfailed",
|
||
|
|
method: request.method(),
|
||
|
|
url,
|
||
|
|
failure: request.failure()?.errorText || "",
|
||
|
|
at: Date.now(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function recordNavigation(page, label, records) {
|
||
|
|
page.on("framenavigated", (frame) => {
|
||
|
|
if (frame === page.mainFrame()) {
|
||
|
|
records.push({ label, url: frame.url(), at: Date.now() });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openDocument(page, workspaceId, documentId) {
|
||
|
|
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||
|
|
waitUntil: "commit",
|
||
|
|
timeout: UI_TIMEOUT_MS,
|
||
|
|
});
|
||
|
|
await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({
|
||
|
|
state: "visible",
|
||
|
|
timeout: UI_TIMEOUT_MS,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openFileTree(page, workspaceId, documentId) {
|
||
|
|
await openDocument(page, workspaceId, documentId);
|
||
|
|
await page.evaluate(() => {
|
||
|
|
const visible = (node) =>
|
||
|
|
node instanceof HTMLElement &&
|
||
|
|
!node.hidden &&
|
||
|
|
getComputedStyle(node).display !== "none" &&
|
||
|
|
getComputedStyle(node).visibility !== "hidden" &&
|
||
|
|
node.getClientRects().length > 0;
|
||
|
|
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||
|
|
if (visible(fileRoot)) return;
|
||
|
|
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||
|
|
if (tab instanceof HTMLElement) tab.click();
|
||
|
|
});
|
||
|
|
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForRows(page, documentIds) {
|
||
|
|
await page.waitForFunction(
|
||
|
|
(ids) =>
|
||
|
|
ids.every(
|
||
|
|
(id) =>
|
||
|
|
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
|
||
|
|
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
|
||
|
|
),
|
||
|
|
documentIds,
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForResyncWithRows(page, documentIds) {
|
||
|
|
await page.waitForFunction(
|
||
|
|
(ids) => {
|
||
|
|
const events = Array.isArray(window.__MNOTE_TASK448_TREE_EVENTS__) ? window.__MNOTE_TASK448_TREE_EVENTS__ : [];
|
||
|
|
const hasResync = events.some((event) => event && event.name === "tree:resync");
|
||
|
|
if (!hasResync) return false;
|
||
|
|
const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
|
||
|
|
const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "";
|
||
|
|
if (applied !== "resync" || error) return false;
|
||
|
|
return ids.every(
|
||
|
|
(id) =>
|
||
|
|
document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) &&
|
||
|
|
document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
documentIds,
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readState(page, rootDocumentId) {
|
||
|
|
return await page.evaluate((rootId) => {
|
||
|
|
const readDirectChildren = (mode) => {
|
||
|
|
const rootSelector =
|
||
|
|
mode === "filetree"
|
||
|
|
? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]`
|
||
|
|
: `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`;
|
||
|
|
const rootRow = document.querySelector(rootSelector);
|
||
|
|
const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children");
|
||
|
|
if (!list) return [];
|
||
|
|
return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({
|
||
|
|
documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "",
|
||
|
|
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||
|
|
title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
|
||
|
|
}));
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
url: window.location.href,
|
||
|
|
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||
|
|
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||
|
|
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||
|
|
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||
|
|
pageOrder: readDirectChildren("page"),
|
||
|
|
fileTreeOrder: readDirectChildren("filetree"),
|
||
|
|
treeEvents: window.__MNOTE_TASK448_TREE_EVENTS__ || [],
|
||
|
|
};
|
||
|
|
}, rootDocumentId);
|
||
|
|
}
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
const stamp = Date.now();
|
||
|
|
const email = `mnote.stage8.resync.${stamp}@example.com`;
|
||
|
|
const prefix = `TEST-10REVIEW-08-RESYNC-${stamp}`;
|
||
|
|
const result = {
|
||
|
|
ok: false,
|
||
|
|
task: TASK,
|
||
|
|
baseUrl: BASE_URL,
|
||
|
|
authBaseUrl: AUTH_BASE_URL,
|
||
|
|
email,
|
||
|
|
prefix,
|
||
|
|
pollMs: 5000,
|
||
|
|
fixture: {},
|
||
|
|
navigationEvents: [],
|
||
|
|
treeEventRequests: [],
|
||
|
|
states: {},
|
||
|
|
screenshots: {
|
||
|
|
bDocument: SCREENSHOT_B_DOCUMENT,
|
||
|
|
bFileTree: SCREENSHOT_B_FILETREE,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||
|
|
const contextA = await browser.newContext();
|
||
|
|
const contextB = await browser.newContext();
|
||
|
|
const documentB = await contextB.newPage();
|
||
|
|
const fileTreeB = await contextB.newPage();
|
||
|
|
const requestA = contextA.request;
|
||
|
|
let workspaceId = "";
|
||
|
|
const cleanupIds = [];
|
||
|
|
|
||
|
|
for (const page of [documentB, fileTreeB]) {
|
||
|
|
await installSlowTreeEventSource(page, result.pollMs);
|
||
|
|
await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests);
|
||
|
|
recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
for (const requestContext of [requestA, contextB.request]) {
|
||
|
|
await authenticate(requestContext, email, `stage8-resync-${stamp}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const root = await createPage(requestA, null, `${prefix}-root`);
|
||
|
|
workspaceId = root.workspaceId;
|
||
|
|
cleanupIds.push(root.documentId);
|
||
|
|
const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId);
|
||
|
|
cleanupIds.push(childA.documentId);
|
||
|
|
result.fixture = {
|
||
|
|
workspaceId,
|
||
|
|
rootId: root.documentId,
|
||
|
|
initialChildId: childA.documentId,
|
||
|
|
};
|
||
|
|
|
||
|
|
await openDocument(documentB, workspaceId, root.documentId);
|
||
|
|
await openFileTree(fileTreeB, workspaceId, root.documentId);
|
||
|
|
await waitForRows(documentB, [childA.documentId]);
|
||
|
|
await waitForRows(fileTreeB, [childA.documentId]);
|
||
|
|
await Promise.all([
|
||
|
|
documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
|
||
|
|
fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }),
|
||
|
|
]);
|
||
|
|
|
||
|
|
result.states.before = {
|
||
|
|
document: await readState(documentB, root.documentId),
|
||
|
|
fileTree: await readState(fileTreeB, root.documentId),
|
||
|
|
};
|
||
|
|
|
||
|
|
const navigationStart = result.navigationEvents.length;
|
||
|
|
const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId);
|
||
|
|
const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId);
|
||
|
|
cleanupIds.push(childB.documentId, childC.documentId);
|
||
|
|
result.fixture.resyncedChildIds = [childB.documentId, childC.documentId];
|
||
|
|
|
||
|
|
await waitForResyncWithRows(documentB, [childB.documentId, childC.documentId]);
|
||
|
|
await waitForResyncWithRows(fileTreeB, [childB.documentId, childC.documentId]);
|
||
|
|
await documentB.waitForTimeout(500);
|
||
|
|
await fileTreeB.waitForTimeout(500);
|
||
|
|
|
||
|
|
result.states.after = {
|
||
|
|
document: await readState(documentB, root.documentId),
|
||
|
|
fileTree: await readState(fileTreeB, root.documentId),
|
||
|
|
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.equal(result.states.after.document.liveApplied, "resync", "B 文档页应通过 resync 应用最新树快照");
|
||
|
|
assert.equal(result.states.after.fileTree.liveApplied, "resync", "B File Tree 应通过 resync 应用最新树快照");
|
||
|
|
assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`);
|
||
|
|
assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`);
|
||
|
|
assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`);
|
||
|
|
|
||
|
|
await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true });
|
||
|
|
await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true });
|
||
|
|
result.ok = true;
|
||
|
|
} catch (error) {
|
||
|
|
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||
|
|
result.failure = {
|
||
|
|
document: await readState(documentB, result.fixture.rootId || "").catch(() => null),
|
||
|
|
fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null),
|
||
|
|
};
|
||
|
|
process.exitCode = 1;
|
||
|
|
} finally {
|
||
|
|
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
|
||
|
|
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||
|
|
});
|
||
|
|
await browser.close().catch(() => undefined);
|
||
|
|
await writeResult(result);
|
||
|
|
}
|
||
|
|
})();
|