import { execFileSync } from "node:child_process"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { Frame, TestInfo } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { copyPluginExample } from "../support/helpers/plugin-fixture"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; import { gotoWorkspace } from "../support/helpers/launcher"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { openFileExplorer, openFileFromExplorer, expectFileTabOpen, } from "../support/helpers/file-explorer"; // Full Chromium includes the PDF viewer; the default headless shell does not. test.use({ channel: "chromium" }); function samplePdf() { const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET"; const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, ]; let pdf = "%PDF-1.4\n%\u0000\u00ff\n"; const offsets = objects.map((object, index) => { const offset = Buffer.byteLength(pdf); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; return offset; }); const xref = Buffer.byteLength(pdf); pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`; pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; return pdf; } async function openFile(page: Page, name: string) { await openFileFromExplorer(page, name); await expectFileTabOpen(page, name); } const WIDE = { width: 1280, height: 900 }; const COMPACT = { width: 390, height: 844 }; async function capture(page: Page, testInfo: TestInfo, name: string) { const file = testInfo.outputPath(`${name}.png`); await page.screenshot({ path: file }); await testInfo.attach(name, { path: file, contentType: "image/png" }); } /** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */ async function captureWideAndCompact( page: Page, testInfo: TestInfo, name: string, ready: () => Promise, ) { await capture(page, testInfo, `${name}-wide`); // The shell survives the breakpoint, so the previous layout's copy can still be on screen right // after a resize. Check `ready` only once the shell reports the new layout. await page.setViewportSize(COMPACT); await expect(page.getByTestId("compact-shell")).toBeVisible(); await ready(); await capture(page, testInfo, `${name}-compact`); await page.setViewportSize(WIDE); await expect(page.getByTestId("wide-shell")).toBeVisible(); await ready(); } /** Turns on vim keybindings without discarding settings the test writes later. */ async function enableVimKeybindings(page: Page) { await page.addInitScript(() => { const key = "@paseo:app-settings"; const settings = JSON.parse(localStorage.getItem(key) ?? "{}"); localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true })); }); } async function chooseRichMarkdown(page: Page) { await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click(); await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click(); await page.getByRole("button", { name: "Use", exact: true }).click(); } async function replaceMarkdown(page: Page, text: string) { await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text); } async function openWithFromTabMenu(page: Page, name: string, option: string) { await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); await page.getByRole("menuitem", { name: "Open with…" }).hover(); await page.getByRole("menuitem", { name: option }).click(); } async function selectFileView(page: Page, view: "Preview" | "Source") { const option = page .getByTestId("file-preview-mode") .filter({ visible: true }) .getByRole("button", { name: view, exact: true }); await option.click(); await expect(option).toHaveAttribute("aria-selected", "true"); } async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") { const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true }); await button.click(); await page.reload(); if (view === "Source") { await expectMarkdownEditor(page, "Edited by Rich Markdown"); } else { await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); } } /** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ async function pdfViewer(page: Page): Promise { const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/; const find = () => page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null; await expect.poll(() => find() !== null).toBe(true); return find()!; } async function expectPdfViewerReady(page: Page) { // A narrow viewer collapses its toolbar to Print and More actions. const viewer = await pdfViewer(page); await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled(); } async function expectMarkdownEditor(page: Page, text: string) { const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }); await expect(editor).toBeVisible(); await expect(editor).toContainText(text); await expect(editor).toBeInViewport({ ratio: 1 }); } test("directory examples mount the PDF frame and write Markdown through the file handle", async ({ page, }, testInfo) => { test.setTimeout(180_000); const pdf = await copyPluginExample("pdf-panel"); const markdown = await copyPluginExample("markdown-panel"); execFileSync( process.platform === "win32" ? "npm.cmd" : "npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: markdown.directory, stdio: "pipe" }, ); const workspace = await seedWorkspace({ repoPrefix: "file-panel-examples-", repo: { files: [ { path: "notes.md", content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n", }, { path: "second.md", content: "# Remembered panel\n" }, { path: "report.pdf", content: samplePdf() }, ], }, }); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); try { await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(pdf.directory); await client.installDirectoryPlugin(markdown.directory); await enableVimKeybindings(page); await page.setViewportSize(WIDE); const gate = await installDaemonWebSocketGate(page); await gotoWorkspace(page, workspace.workspaceId); await openFileExplorer(page); await openFile(page, "report.pdf"); await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute( "src", /^blob:/, ); await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute( "sandbox", ); const documentUrl = await page .getByTitle("Document preview", { exact: true }) .getAttribute("src"); expect( await page.evaluate( async (url) => (await fetch(url!)).blob().then((blob) => blob.type), documentUrl, ), ).toBe("application/pdf"); await expectPdfViewerReady(page); await expect( (await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }), ).toHaveValue("1"); await captureWideAndCompact(page, testInfo, "pdf-example", async () => { await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport(); await expectPdfViewerReady(page); }); await openFile(page, "notes.md"); await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked(); await expect( page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }), ).toBeVisible(); await expect(page.getByRole("radio")).toHaveCount(2); await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => { // The previous layout's picker animates out (the sheet slides down, the dialog fades), so // both pickers are mounted for a moment after a resize. await expect(page.getByRole("button", { name: "Use", exact: true })).toHaveCount(1); await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({ ratio: 1, }); }); await chooseRichMarkdown(page); await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText( "Markdown panel example", ); gate.holdNextClientRequest("fs.file.write.request"); await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n"); await gate.waitForHeldClientRequest(); await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); await capture(page, testInfo, "markdown-example-dirty-wide"); gate.releaseHeldClientRequest(); await expect .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) .toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n"); await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden(); await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => { await expectMarkdownEditor(page, "Edited by Rich Markdown"); }); await rememberMarkdownViewAcrossReload(page, "Preview"); await rememberMarkdownViewAcrossReload(page, "Source"); // "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the // cursor and vim mode in the panel's toolbar. await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo"); await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); await selectFileView(page, "Source"); const source = page.getByTestId("file-source-editor").filter({ visible: true }); await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown"); await source.locator(".cm-line").nth(2).click(); const bar = page.getByTestId("file-panel-bar").filter({ visible: true }); await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible(); await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible(); await capture(page, testInfo, "markdown-example-source-wide"); await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel"); await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n"); await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText( "# External update", ); await replaceMarkdown(page, "# Local edit\n"); await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n"); await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible(); await capture(page, testInfo, "markdown-example-conflict-wide"); await page.getByRole("button", { name: "Overwrite", exact: true }).click(); await expect .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) .toBe("# Local edit\n"); await openFile(page, "second.md"); await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText( "Remembered panel", ); await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden(); await page.setViewportSize(COMPACT); await expectMarkdownEditor(page, "Remembered panel"); } finally { await client.removePlugin("pdf-panel"); await client.removePlugin("markdown-panel"); await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); await client.close(); await workspace.cleanup(); await pdf.cleanup(); await markdown.cleanup(); } });