Files
mnote/scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js

457 lines
17 KiB
JavaScript
Raw Permalink 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";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
/**
* task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
*
* 用途:验证 local folder 非 md 资源 trash workbench 的 restore 流程:
* 1. trash workbench 的 refresh() 是否触发浏览器导航(应否: fetch 替换 innerHTML
* 2. restore 后源文件是否恢复(文件系统断言)
* 3. restore 后 filetree 是否最终显示恢复行(轮询更新)
* 4. restore 后是否设置了 focus / reveal(预期:当前无 focus,记录缺口)
*
* 不修改 Rust / SSR 代码。只验证现有行为、记录证据缺口。
*
* Usage:
* node scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
*
* Env:
* MNOTE_WEB_SMOKE_BASE_URL — 3000 地址,默认 http://127.0.0.1:3000
* MNOTE_SMOKE_UI_TIMEOUT_MS — UI 等待超时,默认 30_000
*/
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task473-local-folder-trash-restore-no-refresh-focus-gap-smoke";
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", TASK);
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", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function writeWorkspaceManifest(root) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:user_real:${TASK}`,
ownerId: "user_real",
createdAt: new Date().toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
/**
* Tree command 快捷请求
*/
async function postTreeCommand(rootUri, action, documentId) {
const response = await fetch(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
body: JSON.stringify({
action,
sourceKind: "local_folder",
rootUri,
documentId,
}),
});
const payload = await response.json().catch(async () => ({ text: await response.text() }));
assert.equal(response.status, 200, `${action} failed: ${JSON.stringify(payload)}`);
return payload;
}
/**
* 快速登录(测试账号)
*/
async function quickLogin(page) {
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
const base =
(typeof BASE_URL !== "undefined" && BASE_URL) ||
(typeof baseUrl !== "undefined" && baseUrl) ||
process.env.MNOTE_UI_BASE_URL ||
"http://127.0.0.1:3000";
const timeout =
(typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) ||
(typeof TIMEOUT !== "undefined" && TIMEOUT) ||
30_000;
if (!String(page.url() || "").includes("/auth")) {
await page.goto(String(base).replace(/\/+$/, "") + "/auth", {
waitUntil: "commit",
timeout,
});
}
await loginViaAuthForm(page, {
baseUrl: base,
timeoutMs: timeout,
gotoAuth: false,
});
await page
.waitForURL((url) => !String(url).includes("/auth"), { timeout })
.catch(() => {});
}
/**
* 构建 filetree 页 URL
*/
function treeUrl(root, mode) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode || "filetree");
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function treeUrlWithRestoreFocus(root, rowId) {
const url = new URL(treeUrl(root, "filetree"));
url.searchParams.set("restoreFocusRowId", rowId);
return url.toString();
}
/**
* 构建 trash 页 URL
*/
function trashUrl(root) {
const url = new URL(`${BASE_URL}/trash`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
/**
* non-md 资源在 filetree 中的 row-id
* 基于 task435 / task464 使用的 data-row-id 格式
*/
function assetRowId(relativePath) {
return `local:asset:${relativePath}`;
}
/**
* filetree 中等待某 row 可见
*/
async function waitForFileTreeRow(page, rowId) {
await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
/**
* filetree 中等待某 row 消失
*/
async function waitForFileTreeRowGone(page, rowId) {
await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
});
}
/**
* 等待 filetree 容器可见
*/
async function waitForFileTreeRoot(page) {
await page.waitForFunction(
() => {
const root = document.getElementById("sidebar-file-tree-root");
return root && root.getClientRects().length > 0;
},
{ timeout: UI_TIMEOUT_MS },
);
}
/**
* 等待 trash workbench 可见
*/
async function waitForTrashWorkbench(page, rootUri) {
const url = trashUrl(rootUri);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-trash-workbench"][data-trash-source-kind="local_folder"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
/**
* 检查 row 是否有 focus / active / selected 属性
*/
function checkFocusEvidence(page, rowId) {
return page.evaluate((id) => {
const row = document.querySelector(`.tree-row[data-row-id="${id}"]`);
if (!row) return { found: false, reason: "row_not_in_dom" };
const attrs = {};
for (const attr of ["data-active", "data-selected", "data-focused", "class"]) {
const val = row.getAttribute(attr);
if (val !== null) attrs[attr] = val;
}
// 检查是否在视口中(粗略)
const rect = row.getBoundingClientRect();
const inViewport = rect.top >= -50 && rect.left >= -50
&& rect.bottom <= (window.innerHeight + 50)
&& rect.right <= (window.innerWidth + 50);
return { found: true, attrs, inViewport, tagName: row.tagName };
}, rowId);
}
async function waitForFocusEvidence(page, rowId) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let last = null;
while (Date.now() < deadline) {
last = await checkFocusEvidence(page, rowId);
const attrs = last?.attrs || {};
const hasFocusEvidence = last?.found && (
attrs["data-active"] === "true"
|| attrs["data-selected"] === "true"
|| attrs["data-focused"] === "true"
|| /\bis-active\b|\bis-selected\b|\bis-focused\b/.test(String(attrs.class || ""))
);
if (hasFocusEvidence) return last;
await page.waitForTimeout(250);
}
return last || checkFocusEvidence(page, rowId);
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-${TASK}-`));
const rootUri = fileUrl(root);
const assetRelativePath = "docs/report.txt";
const assetId = `local-file:${assetRelativePath}`;
const sourcePath = path.join(root, assetRelativePath);
const assetRowIdStr = assetRowId(assetRelativePath);
// 准备 workspace
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
writeWorkspaceManifest(root);
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
fs.writeFileSync(sourcePath, "task473 non-md asset body\n", "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1360, height: 900 },
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 evidence = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
root,
assetRelativePath,
assetId,
steps: [],
focusAfterRestore: null,
restoreFocusDebug: {},
navEventsAfterRestore: [],
restoreNavFree: null,
fileReappearsInFiletree: null,
};
try {
// === 步骤1:登录并打开 filetree 页 ===
await quickLogin(page);
await page.goto(treeUrlWithRestoreFocus(root, assetRowIdStr), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForFileTreeRoot(page);
// 等待初始文件加载
await waitForFileTreeRow(page, "local:markdown:README.md");
await waitForFileTreeRow(page, "local:folder:docs");
await waitForFileTreeRow(page, assetRowIdStr);
evidence.steps.push({ step: 1, label: "登录并打开 filetree", ok: true });
// === 步骤2API archive(删除进入 trash===
const archiveResult = await postTreeCommand(rootUri, "delete", assetId);
assert.equal(archiveResult.result?.execution?.canonicalCommand, "tree.resource.archive");
assert.equal(fs.existsSync(sourcePath), false, "archive 后源文件应移入 .mnote/trash");
evidence.steps.push({
step: 2,
label: "API archive 非 md 文件到 trash",
ok: true,
canonicalCommand: archiveResult.result?.execution?.canonicalCommand,
});
// === 步骤3:等待 filetree 更新(轮询 1200ms===
await waitForFileTreeRowGone(page, assetRowIdStr);
evidence.steps.push({ step: 3, label: "filetree 中文件行消失(轮询更新)", ok: true });
// === 步骤4:打开 trash 页,验证条目可见 ===
await waitForTrashWorkbench(page, root);
const trashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`);
await trashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
evidence.steps.push({ step: 4, label: "trash 页显示被删文件行", ok: true });
// === 步骤5:记录 restore 前的导航事件基线,然后点击恢复 ===
const navBeforeRestore = navigationEvents.length;
const restoreButton = trashRow.getByRole("button", { name: "恢复" });
await restoreButton.click({ timeout: UI_TIMEOUT_MS });
// 等待 trash 行消失(refresh() 后应替换 innerHTML,不会触发页面导航)
await trashRow.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async () => {
// refresh() 完成后新渲染的行会移除旧 DOM,新行也可能出现;
// 如果旧引用还在,等最多 3s 再看
await page.waitForTimeout(3000);
});
const navAfterRestore = navigationEvents.length;
const restoreNavDelta = navAfterRestore - navBeforeRestore;
evidence.restoreFocusDebug.pendingAfterRestore = await page.evaluate(() => (
window.localStorage ? window.localStorage.getItem("mnote.pendingLocalFolderRestoreFiletreeRowId") : null
)).catch(() => null);
evidence.restoreFocusDebug.windowNameAfterRestore = await page.evaluate(() => window.name).catch(() => null);
evidence.restoreNavFree = { navBeforeRestore, navAfterRestore, delta: restoreNavDelta };
evidence.navEventsAfterRestore = navigationEvents.slice(navBeforeRestore);
// 断言:restore 操作不应触发页面导航 / reload
assert.equal(
restoreNavDelta, 0,
`restore 后应无页面导航,实际发生 ${restoreNavDelta} 次导航: ${JSON.stringify(evidence.navEventsAfterRestore)}`,
);
evidence.steps.push({
step: 5,
label: "trash 恢复后无页面导航(no-refresh 验证通过)",
ok: true,
navDelta: restoreNavDelta,
});
// === 步骤6:验证文件系统恢复 ===
await page.waitForTimeout(500); // 等待文件写完成
assert.equal(fs.existsSync(sourcePath), true, "restore 后源文件应恢复到原路径");
evidence.steps.push({ step: 6, label: "文件系统验证文件已恢复", ok: true });
// === 步骤7:返回 filetree 页,验证文件行重现 ===
// 先检查 trash 页的 status bar 是否有"已恢复项目"提示
const statusText = await page.locator('[data-testid="mnote-trash-status"]').textContent({ timeout: 3000 }).catch(() => "");
evidence.steps.push({
step: 7,
label: "trash 页 status bar 检查",
ok: statusText.includes("已恢复") || statusText.length === 0,
statusText,
note: statusText.includes("已恢复") ? "status 提示正确" : "status 提示缺失或无内容",
});
// 回到 filetree 页 — 这一步本身是页面导航(用户主动操作)
const navBeforeFiletree = navigationEvents.length;
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const navAfterFiletreeLoad = navigationEvents.length;
await waitForFileTreeRoot(page);
evidence.restoreFocusDebug.filetreeNavDelta = navAfterFiletreeLoad - navBeforeFiletree;
evidence.restoreFocusDebug.filetreeNavEvents = navigationEvents.slice(navBeforeFiletree);
evidence.restoreFocusDebug.filetreeUrlAfterLoad = page.url();
evidence.restoreFocusDebug.localStorageKeysAfterFiletreeLoad = await page.evaluate(() => (
Object.keys(window.localStorage || {}).filter((key) => key.indexOf("mnote") >= 0)
)).catch(() => []);
// 等待事件驱动 tree live 刷新后文件行重现
try {
await waitForFileTreeRow(page, assetRowIdStr);
evidence.fileReappearsInFiletree = true;
} catch (err) {
// 可能轮询还未触发;再等一轮
await page.waitForTimeout(2000);
try {
await waitForFileTreeRow(page, assetRowIdStr);
evidence.fileReappearsInFiletree = true;
} catch {
evidence.fileReappearsInFiletree = false;
}
}
assert.equal(
evidence.fileReappearsInFiletree, true,
`restore 后 filetree 应通过事件驱动刷新显示文件行(row-id: ${assetRowIdStr}`,
);
evidence.steps.push({
step: 8,
label: "回到 filetree 页面后文件行重现(事件刷新)",
ok: evidence.fileReappearsInFiletree,
});
// === 步骤9:检查 restore 后是否有 focus/reveal ===
const focusData = await waitForFocusEvidence(page, assetRowIdStr).catch(() => checkFocusEvidence(page, assetRowIdStr));
evidence.focusAfterRestore = focusData;
evidence.restoreFocusDebug.pendingAfterFiletreeLoad = await page.evaluate(() => (
window.localStorage ? window.localStorage.getItem("mnote.pendingLocalFolderRestoreFiletreeRowId") : null
)).catch(() => null);
evidence.restoreFocusDebug.windowNameAfterFiletreeLoad = await page.evaluate(() => window.name).catch(() => null);
evidence.restoreFocusDebug.markerAfterFiletreeLoad = await page.evaluate(() => (
document.documentElement.getAttribute("data-mnote-local-folder-restore-focused-row-id")
)).catch(() => null);
evidence.restoreFocusDebug.pendingMarkerAfterFiletreeLoad = await page.evaluate(() => (
document.documentElement.getAttribute("data-mnote-local-folder-restore-pending-row-id")
)).catch(() => null);
evidence.restoreFocusDebug.focusStatusAfterFiletreeLoad = await page.evaluate(() => (
document.documentElement.getAttribute("data-mnote-local-folder-restore-focus-status")
)).catch(() => null);
evidence.restoreFocusDebug.filetreeHtmlSnippet = await page.locator(`#sidebar-file-tree-root .tree-row[data-row-id="${assetRowIdStr}"]`).evaluate((row) => row.outerHTML).catch(() => null);
const hasFocusEvidence = focusData.found && (
focusData.attrs?.["data-active"] === "true"
|| focusData.attrs?.["data-selected"] === "true"
|| focusData.attrs?.["data-focused"] === "true"
|| /\bis-active\b|\bis-selected\b|\bis-focused\b/.test(String(focusData.attrs?.class || ""))
);
const noFocusEvidence = !hasFocusEvidence;
evidence.steps.push({
step: 9,
label: "检查 restore 后 focus/reveal 证据",
ok: !noFocusEvidence, // 期望有focus,但当前预期是没有 -> 记录缺口
focusData,
gap: noFocusEvidence ? "restore 后未发现 focus/active/selected 属性,selectSidebarFileTreeDocument 未被调用" : null,
note: noFocusEvidence
? "【证据缺口】restore 后 filetree 未对恢复行设置 focus/active/selected"
: "restore 后有 focus 证据",
});
evidence.ok = hasFocusEvidence;
} catch (error) {
evidence.error = error instanceof Error ? error.stack || error.message : String(error);
// 尝试读取当前 filetree 文本以便诊断
evidence.pageText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3000 }).catch(() => "");
throw error;
} finally {
// 写结果文件
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ ok: evidence.ok, resultPath: RESULT_PATH, focusAfterRestore: evidence.focusAfterRestore }, null, 2));
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
if (!process.env.MNOTE_KEEP_SMOKE_TMP) {
fs.rmSync(root, { recursive: true, force: true });
}
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});