Files
mnote/scripts/task435-local-folder-watch-no-reload-smoke.js
T

345 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task435-local-folder-watch-no-reload-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function treeUrl(root, mode) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task435`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function waitForVisibleText(page, text) {
await page.waitForFunction(
(expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => {
const textContent = element.textContent ? element.textContent.trim() : "";
if (textContent !== expectedText) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}),
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForAnyPageTreeNode(page, documentIdPrefix) {
await page.waitForFunction(
(prefix) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
.some((row) => {
const id = row.getAttribute("data-node-id") || "";
return id.startsWith(prefix);
}),
documentIdPrefix,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForGone(page, selector) {
await page.locator(selector).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
async function waitForFileTreeRow(page, rowId) {
await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function expandFileTreeFolder(page, rowId) {
const rowSelector = `.tree-row[data-row-id="${rowId}"]`;
await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(selector) => {
const row = document.querySelector(selector);
return !!row;
},
rowSelector,
{ timeout: UI_TIMEOUT_MS },
);
const expanded = await page.locator(rowSelector).getAttribute("aria-expanded").catch(() => null);
if (expanded === "true") return;
await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(selector) => {
const row = document.querySelector(selector);
return row && row.getAttribute("aria-expanded") === "true";
},
rowSelector,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForFileTreeRowGone(page, rowId) {
await waitForGone(page, `.tree-row[data-row-id="${rowId}"]`);
}
async function waitForPageTreeNode(page, documentId) {
await page.waitForFunction(
(expectedDocumentId) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
.some((row) => row.getAttribute("data-node-id") === expectedDocumentId),
documentId,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPageTreeNodeGone(page, documentId) {
await page.waitForFunction(
(expectedDocumentId) => !Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
.some((row) => row.getAttribute("data-node-id") === expectedDocumentId),
documentId,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForLocalFolderWatchProjectionApplied(page) {
await page.waitForFunction(
() => {
const value = document.documentElement.getAttribute("data-mnote-local-folder-watch-applied") || "";
return value === "projection" || value === "watch_batch";
},
{ timeout: UI_TIMEOUT_MS },
);
const appliedValue = await page.evaluate(
() => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied"),
);
assert(
appliedValue === "projection" || appliedValue === "watch_batch",
`local_folder 变更应通过事件驱动 projection/watch_batch 应用,实际 data-mnote-local-folder-watch-applied=${appliedValue}`,
);
}
async function runStep(page, label, navigationEvents, action) {
const before = navigationEvents.length;
await page.evaluate(() => {
document.documentElement.removeAttribute("data-mnote-local-folder-watch-applied");
});
await action();
const after = navigationEvents.length;
assert(after === before, `${label} 不应触发浏览器导航或 reloadbefore=${before} after=${after}`);
return { label, navigationEventsBefore: before, navigationEventsAfter: after };
}
async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-watch-no-reload-"));
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable.md"), "# Stable Page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable-asset.txt"), "stable asset", "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const navigationEvents = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
navigationEvents.push({ url: frame.url(), timestamp: Date.now() });
}
});
const steps = [];
try {
await quickLogin(page);
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForAnyPageTreeNode(page, "local-md:");
await waitForPageTreeNode(page, localMdDocumentId("docs/stable.md"));
await page.waitForFunction(
() => {
const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || "";
return transport === "local-folder-events";
},
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForTimeout(100);
navigationEvents.length = 0;
steps.push(await runStep(page, "Markdown 外部创建后 page tree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-create.md"), "# Watcher Create\n", "utf8");
await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-create.md"));
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "Markdown 外部重命名后 page tree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-create.md"),
path.join(root, "docs", "watcher-renamed.md"),
);
await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-renamed.md"));
await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-create.md"));
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "Markdown 外部删除后 page tree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-renamed.md"));
await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-renamed.md"));
await waitForLocalFolderWatchProjectionApplied(page);
}));
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForFileTreeRow(page, "local:folder:docs");
await expandFileTreeFolder(page, "local:folder:docs");
await waitForFileTreeRow(page, "local:asset:docs/stable-asset.txt");
await page.waitForFunction(
() => {
const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || "";
return transport === "local-folder-events";
},
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForTimeout(100);
navigationEvents.length = 0;
steps.push(await runStep(page, "Markdown 外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-filetree-md.md"), "# Watcher Filetree Markdown\n", "utf8");
await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md.md");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "Markdown 外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-filetree-md.md"),
path.join(root, "docs", "watcher-filetree-md-renamed.md"),
);
await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md-renamed.md");
await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md.md");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "Markdown 外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-filetree-md-renamed.md"));
await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md-renamed.md");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
await waitForFileTreeRow(page, "local:asset:docs/watcher-asset.txt");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-asset.txt"),
path.join(root, "docs", "watcher-asset-renamed.txt"),
);
await waitForFileTreeRow(page, "local:asset:docs/watcher-asset-renamed.txt");
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset.txt");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-asset-renamed.txt"));
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset-renamed.txt");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "第二类非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-image.png"), "png", "utf8");
await waitForFileTreeRow(page, "local:asset:docs/watcher-image.png");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "第二类非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-image.png"),
path.join(root, "docs", "watcher-image-renamed.png"),
);
await waitForFileTreeRow(page, "local:asset:docs/watcher-image-renamed.png");
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image.png");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep(page, "第二类非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-image-renamed.png"));
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image-renamed.png");
await waitForLocalFolderWatchProjectionApplied(page);
}));
const result = {
ok: true,
baseUrl: BASE_URL,
root,
steps,
navigationEvents,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`task435 local folder watcher no-reload smoke passed: ${RESULT_PATH}`);
} finally {
await browser.close().catch(() => {});
fs.rmSync(root, { recursive: true, force: true });
}
}
run().catch((error) => {
const result = {
ok: false,
baseUrl: BASE_URL,
error: error && error.stack ? error.stack : String(error),
};
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.error(error);
process.exit(1);
});