#!/usr/bin/env node const assert = require("assert"); 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"; const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000); const ACTOR_ID = `smoke-user-${process.pid}-${Date.now()}`; const MANAGED_DATA_ROOT = "/mnt/Data1T/Mnote_data"; function resolveChromiumExecutablePath() { const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; if (explicit && fs.existsSync(explicit)) return explicit; return [ "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium", "/usr/bin/chromium-browser", ].find((candidate) => fs.existsSync(candidate)) || ""; } function fileUrl(filePath) { return `file://${filePath.split(path.sep).map((part, index) => ( index === 0 ? "" : encodeURIComponent(part) )).join("/")}`; } function managedActorDir(actorId) { return path.join(MANAGED_DATA_ROOT, "users", actorId, "workspaces", "my-space"); } function writeWorkspaceManifest(root, ownerId) { fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), JSON.stringify({ workspaceId: `local-ws-${path.basename(root).replace(/[^a-zA-Z0-9_-]/g, "_")}`, ownerId, createdAt: new Date(0).toISOString(), capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"], }, null, 2), "utf8"); } async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main-local-folder-")); const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main 本地 #other-")); fs.mkdirSync(path.join(root, "docs"), { recursive: true }); fs.writeFileSync(path.join(root, "README.md"), "# Root Page\n", "utf8"); fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child Page\n", "utf8"); fs.writeFileSync(path.join(root, "plain.txt"), "plain asset\n", "utf8"); fs.writeFileSync(path.join(otherRoot, "OTHER.md"), "# Other Root\n", "utf8"); writeWorkspaceManifest(root, ACTOR_ID); writeWorkspaceManifest(otherRoot, ACTOR_ID); const executablePath = resolveChromiumExecutablePath(); const browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}), }); const context = await browser.newContext({ extraHTTPHeaders: { "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); const dialogs = []; page.on("dialog", async (dialog) => { dialogs.push(dialog.type()); await dialog.dismiss().catch(() => {}); }); try { await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const managedRoot = managedActorDir(ACTOR_ID); const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first(); if (await createButton.isVisible({ timeout: 2_000 }).catch(() => false)) { await page.locator('[data-testid="mnote-open-local-folder-empty"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await createButton.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => { return url.origin === new URL(BASE_URL).origin && url.pathname === "/" && url.searchParams.get("sourceKind") === "local_folder" && url.searchParams.get("rootUri") === fileUrl(managedRoot) && url.searchParams.get("treeView") === "filetree"; }, { timeout: UI_TIMEOUT_MS }); } else { await page.locator(".sidebar-workspace-name").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } assert( fs.existsSync(path.join(managedRoot, ".mnote", "workspace.json")), "创建我的空间应写入受管 workspace manifest", ); await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); assert.deepStrictEqual(dialogs, [], "打开本地文件夹不能再触发 prompt/alert/confirm 原生弹窗"); await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root); await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click(); await page.waitForURL((url) => { return url.origin === new URL(BASE_URL).origin && url.pathname === "/" && url.searchParams.get("sourceKind") === "local_folder" && url.searchParams.get("rootUri") === fileUrl(root) && url.searchParams.get("treeView") === "filetree"; }, { timeout: UI_TIMEOUT_MS }); const recentLocalRoots = await page.evaluate(() => { const actorId = document.body.getAttribute("data-mnote-actor-id") || ""; const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]"; return JSON.parse(raw); }); assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri"); const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS }); assert(!bodyText.includes("legacy_next_compat"), "本地文件夹主入口不能出现已退役的 legacy Next 文案"); const readmeRow = page.locator('.tree-row[data-row-id="local:markdown:README.md"]'); const docsRow = page.locator('.tree-row[data-row-id="local:folder:docs"]'); await readmeRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await docsRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await readmeRow.locator(":scope > .tree-link").click(); await readmeRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await docsRow.locator(":scope > .tree-link").click({ modifiers: ["Shift"] }); const selectedRowIds = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]') .evaluateAll((rows) => rows.map((row) => row.getAttribute("data-row-id"))); assert( selectedRowIds.includes("local:markdown:README.md") && selectedRowIds.includes("local:folder:docs") && selectedRowIds.length >= 2, `Shift+Click 应形成文件树范围多选,实际选中: ${selectedRowIds.join(", ")}`, ); await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator(`[data-testid="mnote-recent-local-root"][data-root-uri="${fileUrl(root)}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await page.locator('[data-testid="mnote-open-other-local-folder"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(otherRoot); await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => { return url.pathname === "/" && url.searchParams.get("sourceKind") === "local_folder" && url.searchParams.get("rootUri") === fileUrl(otherRoot) && url.searchParams.get("treeView") === "filetree"; }, { timeout: UI_TIMEOUT_MS }); const updatedRecentLocalRoots = await page.evaluate(() => { const actorId = document.body.getAttribute("data-mnote-actor-id") || ""; const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]"; return JSON.parse(raw); }); assert.equal(updatedRecentLocalRoots[0], fileUrl(otherRoot), "切到其他本地文件夹后应把它放到最近目录首位"); assert.equal(updatedRecentLocalRoots[1], fileUrl(root), "之前打开过的本地目录应保留在最近目录列表中"); const legacyRecentLocalRoots = await page.evaluate(() => { return window.localStorage.getItem("mnote.localFolder.recentRoots"); }); assert.equal(legacyRecentLocalRoots, null, "最近本地目录不能写入跨用户共享的 legacy localStorage key"); await page.locator('.tree-row[data-row-id="local:markdown:OTHER.md"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-open-other-local-folder"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const recentPathInputValue = await page.locator('[data-testid="mnote-local-folder-path-input"]').inputValue({ timeout: UI_TIMEOUT_MS, }); assert.equal(recentPathInputValue, otherRoot, "最近目录回填输入框时应解码 file:// rootUri,避免二次编码"); await page.getByRole("button", { name: "取消" }).click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-switch-cloud-workspace"]').click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => { return url.pathname === "/" && url.searchParams.get("mnoteHome") === "1" && !url.searchParams.has("sourceKind") && !url.searchParams.has("rootUri"); }, { timeout: UI_TIMEOUT_MS }); await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const finalUrl = new URL(page.url()); assert( finalUrl.pathname === "/" && !page.url().includes("legacy_next_compat"), "回到主入口后应仍由 Rust Web 主壳承载", ); console.log("task164 desktop hot local folder main entry smoke passed"); } finally { await browser.close(); fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(otherRoot, { recursive: true, force: true }); fs.rmSync(path.join(MANAGED_DATA_ROOT, "users", ACTOR_ID), { recursive: true, force: true }); } } main().catch((error) => { console.error(error); process.exit(1); });