Files
mnote/scripts/task487-local-folder-tree-live-consumer-smoke.js
T

468 lines
18 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.
#!/usr/bin/env node
"use strict";
/**
* task487 — Local Folder Tree Live Consumer smoke
*
* STATUS: 🔴 RED (预期失败)
*
* 本 smoke 验证 local_folder 文档页的 Tree Live Consumer 收敛目标:
* 1. data-mnote-tree-live-transport 不再等于 "local-folder-static"(应改为
* "local-folder-events" 或等价 tree live transport
* 2. 外部文件变化后,Sidebar / FileTree 刷新通过 tree live consumer 完成
* data-mnote-tree-live-applied 为 "snapshot" / "resync"
* 3. data-mnote-local-folder-watch-applied 不再是唯一刷新证据
*
* 当前为 REDWorkers A(后端 local_folder tree live event stream)和
* Worker B(前端 tree live controller 接入 local_folder)尚未完成。
* 断言 1~3 当前均预期失败。——本 smoke 用于验收,直至三个断言全 PASS
* 后收敛 checklist 才能归档。
*/
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", "task487-local-folder-tree-live-consumer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PAGE_TREE_LOADED_PATH = path.join(OUTPUT_DIR, "page-tree-loaded.png");
const SCREENSHOT_PAGE_TREE_AFTER_CREATE_PATH = path.join(OUTPUT_DIR, "page-tree-after-create.png");
const SCREENSHOT_PAGE_TREE_AFTER_DELETE_PATH = path.join(OUTPUT_DIR, "page-tree-after-delete.png");
const SCREENSHOT_FILETREE_AFTER_CREATE_PATH = path.join(OUTPUT_DIR, "filetree-after-create.png");
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 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 documentUrl(root, relativePath, mode) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
if (mode) url.searchParams.set("treeView", mode);
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}:task487`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
/**
* 读取 <html> 上的所有 data-mnote-tree-live-* 属性和
* data-mnote-local-folder-watch-applied,并补充当前树 DOM 证据。
*/
async function readLiveDomState(page, evidence = {}) {
return page.evaluate((options) => {
const html = document.documentElement;
const pageTreeRows = Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"));
const fileTreeRows = Array.from(document.querySelectorAll(".tree-row[data-row-id]"));
const targetNodeId = options.targetNodeId || "";
const targetRowSelector = options.targetRowSelector || "";
const targetNodePresent = targetNodeId
? pageTreeRows.some((row) => row.getAttribute("data-node-id") === targetNodeId)
: null;
const targetRowPresent = targetRowSelector
? !!document.querySelector(targetRowSelector)
: null;
return {
label: options.label || "",
treeLiveTransport: html.getAttribute("data-mnote-tree-live-transport") || "",
treeLiveStatus: html.getAttribute("data-mnote-tree-live-status") || "",
treeLiveApplied: html.getAttribute("data-mnote-tree-live-applied") || "",
treeLiveRevision: html.getAttribute("data-mnote-tree-live-revision") || "",
treeLiveApplyError: html.getAttribute("data-mnote-tree-live-apply-error") || "",
localFolderWatchApplied: html.getAttribute("data-mnote-local-folder-watch-applied") || "",
pageTreeRowCount: pageTreeRows.length,
fileTreeRowCount: fileTreeRows.length,
targetNodeId,
targetNodePresent,
targetRowSelector,
targetRowPresent,
url: window.location.href,
};
}, evidence);
}
/**
* 记录一个阶段内是否发生主框架导航。RED smoke 不因失败退出非 0
* 但需要把 reload / full navigation 作为明确证据写入 result.json。
*/
function navigationEvidence(label, events) {
return {
label,
pass: events.length === 0,
count: events.length,
events: events.slice(0, 20),
};
}
/**
* 断言 helper:检查条件,返回 { pass, expected, actual, label }
*/
function check(label, condition, expected, actual) {
return {
label,
pass: !!condition,
expected: String(expected),
actual: String(actual),
};
}
/**
* 等待 page tree row 出现(基于 task435 模式)
*/
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 run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-tree-live-consumer-"));
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");
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 assertions = [];
let pageTreeLoadedState = null;
let pageTreeAfterCreateState = null;
let pageTreeAfterDeleteState = null;
let filetreeLoadedState = null;
let filetreeAfterCreateState = null;
let filetreeAfterDeleteState = null;
let pageTreeNavigationEvidence = null;
let filetreeNavigationEvidence = null;
let fatalError = null;
try {
// ── 登录 ──
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 });
}
// ── Phase 1: Page Tree 视图 ──
await page.goto(documentUrl(root, "README.md", "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
// 等待页面稳定
await page.waitForFunction(
() => {
const html = document.documentElement;
const transport = html.getAttribute("data-mnote-tree-live-transport") || "";
return transport !== "";
},
{ timeout: UI_TIMEOUT_MS },
).catch(() => {});
await page.waitForTimeout(300);
navigationEvents.length = 0;
// 记录加载后 DOM 状态
pageTreeLoadedState = await readLiveDomState(page, { label: "pageTreeLoaded" });
// 断言 1: transport 不应为 local-folder-static
assertions.push(check(
"data-mnote-tree-live-transport !== 'local-folder-static'",
pageTreeLoadedState.treeLiveTransport !== "local-folder-static",
'非 local-folder-static(期望 "local-folder-events" 或等价)',
pageTreeLoadedState.treeLiveTransport,
));
// 断言 2: status 不应为 static
assertions.push(check(
"data-mnote-tree-live-status 不为 static",
pageTreeLoadedState.treeLiveStatus !== "static",
"connected / connecting / disabled 等非 static 值",
pageTreeLoadedState.treeLiveStatus,
));
// 断言 3: 有 transport 属性(不是空)
assertions.push(check(
"data-mnote-tree-live-transport 非空",
pageTreeLoadedState.treeLiveTransport !== "",
"非空字符串",
pageTreeLoadedState.treeLiveTransport,
));
// 截图:page tree 加载后状态
await page.screenshot({ path: SCREENSHOT_PAGE_TREE_LOADED_PATH, fullPage: false });
// 断言 4: 外部文件创建后,tree live applied 应为 snapshot 或 resync
// 先确认页面稳定
await page.waitForTimeout(200);
pageTreeLoadedState = await readLiveDomState(page, { label: "pageTreeLoadedAfterStabilize" });
// 创建外部文件
const createdFileRel = "docs/live-consumer-test.md";
const createdFileDocumentId = localMdDocumentId(createdFileRel);
fs.writeFileSync(path.join(root, createdFileRel), "# Live Consumer Test\n", "utf8");
// 等待 page tree row 出现(当前通过 polling 机制)
await waitForPageTreeNode(page, createdFileDocumentId).catch(() => {});
// 等待一小段时间让 tree live consumer 有机会触发
await page.waitForTimeout(400);
pageTreeAfterCreateState = await readLiveDomState(page, {
label: "pageTreeAfterExternalCreate",
targetNodeId: createdFileDocumentId,
});
await page.screenshot({ path: SCREENSHOT_PAGE_TREE_AFTER_CREATE_PATH, fullPage: false });
// 断言 4: applied 应为 snapshot 或 resync
const appliedOk = pageTreeAfterCreateState.treeLiveApplied === "snapshot"
|| pageTreeAfterCreateState.treeLiveApplied === "resync";
assertions.push(check(
"外部创建后 data-mnote-tree-live-applied 为 snapshot|resync",
appliedOk,
"snapshot 或 resync",
pageTreeAfterCreateState.treeLiveApplied,
));
// 断言 5: watch-applied 不再是唯一证据
// 即 treeLiveApplied 存在(非空)或 transport 不是 local-folder-static
// 如果 watch-applied 是唯一标记而 treeLiveApplied 为空,则不合格
const hasTreeLiveEvidence = pageTreeAfterCreateState.treeLiveApplied !== ""
|| pageTreeAfterCreateState.treeLiveTransport !== "local-folder-static";
assertions.push(check(
"存在 tree live consumer 证据(非仅 local-folder-watch-applied",
hasTreeLiveEvidence,
"treeLiveApplied 非空 或 transport !== local-folder-static",
`treeLiveApplied="${pageTreeAfterCreateState.treeLiveApplied}" transport="${pageTreeAfterCreateState.treeLiveTransport}"`,
));
// 断言 6: 外部文件删除后同样有 tree live applied 痕迹
fs.rmSync(path.join(root, createdFileRel));
await page.waitForFunction(
(expectedId) => !Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
.some((row) => row.getAttribute("data-node-id") === expectedId),
localMdDocumentId(createdFileRel),
{ timeout: UI_TIMEOUT_MS },
).catch(() => {});
await page.waitForTimeout(400);
pageTreeAfterDeleteState = await readLiveDomState(page, {
label: "pageTreeAfterExternalDelete",
targetNodeId: createdFileDocumentId,
});
await page.screenshot({ path: SCREENSHOT_PAGE_TREE_AFTER_DELETE_PATH, fullPage: false });
const appliedDelOk = pageTreeAfterDeleteState.treeLiveApplied === "snapshot"
|| pageTreeAfterDeleteState.treeLiveApplied === "resync";
assertions.push(check(
"外部删除后 data-mnote-tree-live-applied 为 snapshot|resync",
appliedDelOk,
"snapshot 或 resync",
pageTreeAfterDeleteState.treeLiveApplied,
));
pageTreeNavigationEvidence = navigationEvidence("PageTree 外部创建/删除阶段无主框架导航或 reload", navigationEvents);
assertions.push(check(
pageTreeNavigationEvidence.label,
pageTreeNavigationEvidence.pass,
"navigationEvents.length === 0",
pageTreeNavigationEvidence.count,
));
// ── Phase 2: FileTree 视图 ──
await page.goto(documentUrl(root, "README.md", "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const html = document.documentElement;
const transport = html.getAttribute("data-mnote-tree-live-transport") || "";
return transport !== "";
},
{ timeout: UI_TIMEOUT_MS },
).catch(() => {});
await page.waitForTimeout(300);
navigationEvents.length = 0;
filetreeLoadedState = await readLiveDomState(page, { label: "filetreeLoaded" });
assertions.push(check(
"FileTree: transport !== 'local-folder-static'",
filetreeLoadedState.treeLiveTransport !== "local-folder-static",
"非 local-folder-static",
filetreeLoadedState.treeLiveTransport,
));
assertions.push(check(
"FileTree: status 不为 static",
filetreeLoadedState.treeLiveStatus !== "static",
"非 static",
filetreeLoadedState.treeLiveStatus,
));
// FileTree 外部创建
const filetreeCreatedRel = "docs/filetree-live-test.md";
const filetreeCreatedRowSelector = `.tree-row[data-row-id="local:markdown:${filetreeCreatedRel}"]`;
fs.writeFileSync(path.join(root, filetreeCreatedRel), "# FileTree Live\n", "utf8");
// 等待 filetree row 出现(与 task435 同模式)
await page.locator(filetreeCreatedRowSelector).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
}).catch(() => {});
await page.waitForTimeout(400);
filetreeAfterCreateState = await readLiveDomState(page, {
label: "filetreeAfterExternalCreate",
targetRowSelector: filetreeCreatedRowSelector,
});
await page.screenshot({ path: SCREENSHOT_FILETREE_AFTER_CREATE_PATH, fullPage: false });
assertions.push(check(
"FileTree 外部创建后 tree-live-applied 为 snapshot|resync",
filetreeAfterCreateState.treeLiveApplied === "snapshot" || filetreeAfterCreateState.treeLiveApplied === "resync",
"snapshot 或 resync",
filetreeAfterCreateState.treeLiveApplied,
));
fs.rmSync(path.join(root, filetreeCreatedRel));
await page.locator(filetreeCreatedRowSelector).waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
}).catch(() => {});
await page.waitForTimeout(400);
filetreeAfterDeleteState = await readLiveDomState(page, {
label: "filetreeAfterExternalDelete",
targetRowSelector: filetreeCreatedRowSelector,
});
assertions.push(check(
"FileTree 外部删除后 tree-live-applied 为 snapshot|resync",
filetreeAfterDeleteState.treeLiveApplied === "snapshot" || filetreeAfterDeleteState.treeLiveApplied === "resync",
"snapshot 或 resync",
filetreeAfterDeleteState.treeLiveApplied,
));
filetreeNavigationEvidence = navigationEvidence("FileTree 外部创建/删除阶段无主框架导航或 reload", navigationEvents);
assertions.push(check(
filetreeNavigationEvidence.label,
filetreeNavigationEvidence.pass,
"navigationEvents.length === 0",
filetreeNavigationEvidence.count,
));
} catch (err) {
fatalError = err && err.stack ? err.stack : String(err);
} finally {
// 不论是否异常,都输出结果
const allPassed = assertions.every((a) => a.pass);
const result = {
ok: allPassed,
red: !allPassed,
baseUrl: BASE_URL,
outputDir: OUTPUT_DIR,
summary: allPassed
? "✅ 所有断言通过 — local_folder 已收敛到 tree live consumer"
: "🔴 RED — 一个或多个断言失败,local_folder 仍使用 local-folder-static / polling 主链",
assertions,
domStateBefore: pageTreeLoadedState,
domStateAfterCreate: pageTreeAfterCreateState,
domStateAfterDelete: pageTreeAfterDeleteState,
stagedDomStates: {
pageTreeLoaded: pageTreeLoadedState,
pageTreeAfterExternalCreate: pageTreeAfterCreateState,
pageTreeAfterExternalDelete: pageTreeAfterDeleteState,
filetreeLoaded: filetreeLoadedState,
filetreeAfterExternalCreate: filetreeAfterCreateState,
filetreeAfterExternalDelete: filetreeAfterDeleteState,
},
navigationEvidence: {
pageTreeExternalMutations: pageTreeNavigationEvidence,
filetreeExternalMutations: filetreeNavigationEvidence,
},
navigationEvents: navigationEvents.slice(0, 20),
screenshots: {
pageTreeLoaded: SCREENSHOT_PAGE_TREE_LOADED_PATH,
pageTreeAfterExternalCreate: SCREENSHOT_PAGE_TREE_AFTER_CREATE_PATH,
pageTreeAfterExternalDelete: SCREENSHOT_PAGE_TREE_AFTER_DELETE_PATH,
filetreeAfterExternalCreate: SCREENSHOT_FILETREE_AFTER_CREATE_PATH,
},
fatalError,
timestamp: new Date().toISOString(),
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`task487 结果: ${RESULT_PATH}`);
console.log(JSON.stringify({ ok: result.ok, red: result.red, assertionSummary: assertions.map((a) => ({
label: a.label,
pass: a.pass,
actual: a.actual,
})) }, null, 2));
await browser.close().catch(() => {});
fs.rmSync(root, { recursive: true, force: true });
// 退出码:RED smoke 不 process.exit(1),让 CI 可收集失败证据
if (!allPassed) {
console.error("🔴 RED smoke — 预期失败(Workers A+B 尚未完成)");
process.exit(0); // RED smoke 退出 0 以便 CI 管道记录而非阻断
}
}
}
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(0); // RED smoke 不阻断 CI
});