diff --git a/.env.example b/.env.example deleted file mode 100644 index 75e5a73..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# Set to 1 only when verifying desktop E2E failure artifacts. -LEAFDOWN_E2E_FORCE_FAILURE=0 diff --git a/.gitignore b/.gitignore index 2dc4772..d2d61d2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ node_modules/ # local environment .env .env.* -!.env.example # build output dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73fc92e..59742d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,17 +74,23 @@ Verify a fresh development environment with: pnpm check ``` -Run the Windows-local assembled desktop smoke test with: +Run the Windows-local assembled desktop E2E suite with: ```powershell pnpm test:e2e:desktop ``` -This explicit smoke test is not part of `pnpm check`. It builds an isolated debug binary with test-only WebDriver capabilities, starts one embedded WebDriver worker on port 4445, and exercises the real Tauri application and IPC boundary. The embedded provider does not require an external WebDriver. Keep port 4445 available while it runs. The test identifier and target directory are separate from ordinary Leafdown builds. +This explicit suite is not part of `pnpm check`. It builds an isolated debug binary with test-only WebDriver capabilities, then runs one embedded WebDriver worker at a time on port 4445 across fresh application sessions. The embedded provider does not require an external WebDriver. Keep port 4445 available while it runs. The test identifier, persisted store, and target directory are separate from ordinary Leafdown builds. -Each run writes ignored runner, frontend, and backend logs under `e2e/desktop/artifacts//`. A failed test also captures a screenshot, the real diagnostics summary, the test error, and a semantic UI snapshot that excludes editor content. These artifacts are retained until manually deleted. Treat them as potentially sensitive because diagnostics and errors may contain local paths. +The runner resets only the isolated E2E persisted store, leaving the application to write its own defaults, creates temporary filesystem fixtures, and removes both after the suite. Each run writes ignored runner, frontend, backend, and focused diagnostic evidence under `e2e/desktop/artifacts///`. A failed test also captures a screenshot, the real diagnostics summary, the test error, and a semantic UI snapshot that excludes editor content. A failed run additionally writes `fixture-manifest.json` under `e2e/desktop/artifacts//`, recording each temporary fixture's path, expected and actual hash and size, and modification time before cleanup removes it. These artifacts are retained until manually deleted. Treat them as potentially sensitive because diagnostics and errors may contain local paths. -To verify the failure-evidence path, temporarily set `LEAFDOWN_E2E_FORCE_FAILURE=1` in `.env`, and run the smoke test. The test should fail and retain its evidence. +To verify the failure-evidence path, run the suite with the forced-failure flag: + +```powershell +$env:LEAFDOWN_E2E_FORCE_FAILURE=1; pnpm test:e2e:desktop; $env:LEAFDOWN_E2E_FORCE_FAILURE=$null +``` + +The Diagnostics scenario should fail, retain its evidence, clean its fixture and store state, and return a nonzero exit code. Before substantial implementation, read the relevant sections of [`docs/architecture.md`](./docs/architecture.md) and [`docs/patterns.md`](./docs/patterns.md). @@ -218,7 +224,7 @@ The [Leafdown Project](https://github.com/users/Azganoth/projects/7) contains th | --------------------------- | ----------------------- | | Run the desktop application | `pnpm tauri dev` | | Run the web frontend only | `pnpm dev` | -| Run the desktop smoke test | `pnpm test:e2e:desktop` | +| Run the desktop E2E suite | `pnpm test:e2e:desktop` | | Check frontend changes | `pnpm check:frontend` | | Check backend changes | `pnpm check:backend` | | Check the whole repository | `pnpm check` | diff --git a/docs/architecture.md b/docs/architecture.md index 0bda88f..e15926f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,6 +169,8 @@ Automated tests focus on: - Literal HTML rendering and script-execution prevention. - Context popup layout and caret-based marker visibility. -The Windows-local assembled desktop smoke test complements those component and boundary tests. It runs one worker against an isolated debug binary, uses semantic UI interactions to open Help → Diagnostics, and verifies the visible summary against the real Tauri diagnostics command. Direct bridge execution may corroborate setup or diagnostic state, but it is not a substitute for the user-visible acceptance path. WebDriver plugins, permissions, and frontend integration remain limited to the dedicated desktop E2E build and are excluded from ordinary application builds. +The Windows-local assembled desktop E2E suite complements those component and boundary tests without replacing them. It runs one embedded WebDriver worker at a time against an isolated debug binary and starts fresh application processes for independent scenarios. The suite retains the Help → Diagnostics smoke path, then adds narrow assembled-boundary assertions for the document lifecycle, real folder-watcher refresh, typed backend error propagation, persisted settings across restart, injected frame controls, and the clean window-close handshake. + +User-visible acceptance paths use semantic UI interactions. Direct bridge execution is limited to corroborating diagnostic state, while Node-side filesystem, persisted-store, log, and process access provides deterministic setup or evidence around the native boundary. WebDriver plugins, permissions, and frontend integration remain limited to the dedicated desktop E2E build and are excluded from ordinary application builds. The manual [Markdown corpus](../corpus/README.md) complements automated tests for parsing, rendering, editing, serialization, folder navigation, and local resources. Keep corpus scenarios aligned with the specification when supported behavior changes; use its README for fixture taxonomy and byte-sensitive handling. diff --git a/e2e/desktop/fixtures/document-lifecycle.md b/e2e/desktop/fixtures/document-lifecycle.md new file mode 100644 index 0000000..30476dc --- /dev/null +++ b/e2e/desktop/fixtures/document-lifecycle.md @@ -0,0 +1 @@ +Initial fixture marker. diff --git a/e2e/desktop/fixtures/folder-context/readme.md b/e2e/desktop/fixtures/folder-context/readme.md new file mode 100644 index 0000000..c88796e --- /dev/null +++ b/e2e/desktop/fixtures/folder-context/readme.md @@ -0,0 +1 @@ +Folder index fixture marker. diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts new file mode 100644 index 0000000..5b4b96e --- /dev/null +++ b/e2e/desktop/run.ts @@ -0,0 +1,229 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +import type { DesktopE2ERunContext } from "./support/runContext.js"; +import { RUN_LABEL, WEBDRIVER_PORT } from "./support/suite.js"; + +interface Scenario { + name: string; + continues?: string; + recentFiles?: string[]; + recentFolders?: string[]; +} + +const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); +const artifactsRoot = path.join(repositoryRoot, "e2e", "desktop", "artifacts", RUN_LABEL); +const contextPath = path.join(artifactsRoot, "run-context.json"); +const e2eAppDataDirectory = path.join(process.env.APPDATA ?? "", "com.azganoth.leafdown.e2e"); +const e2eStoreDirectory = path.join(e2eAppDataDirectory, "tauri-plugin-zustand"); +const recentItemsPath = path.join(e2eStoreDirectory, "recent-items.dev.json"); +const settingsPath = path.join(e2eStoreDirectory, "settings.dev.json"); + +const writeJson = (filePath: string, value: unknown) => + writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); + +const resetPersistedState = async (recentFiles: string[] = [], recentFolders: string[] = []) => { + await rm(e2eStoreDirectory, { force: true, recursive: true }); + await mkdir(e2eStoreDirectory, { recursive: true }); + await writeJson(recentItemsPath, { recentFiles, recentFolders, version: 1 }); +}; + +const runWdio = (scenario: Scenario) => + new Promise((resolve, reject) => { + const wdioExecutable = path.join( + repositoryRoot, + "node_modules", + "@wdio", + "cli", + "bin", + "wdio.js", + ); + + const child = spawn(process.execPath, [wdioExecutable, "run", "e2e/desktop/wdio.conf.ts"], { + cwd: repositoryRoot, + env: { + ...process.env, + LEAFDOWN_E2E_ARTIFACT_RUN: RUN_LABEL, + LEAFDOWN_E2E_CONTEXT_PATH: contextPath, + LEAFDOWN_E2E_SCENARIO: scenario.name, + LEAFDOWN_E2E_SPEC: `e2e/desktop/specs/${scenario.name}.e2e.ts`, + }, + stdio: "inherit", + }); + + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + reject( + new Error( + `Desktop E2E scenario ${scenario.name} failed (code=${String(code)}, signal=${String(signal)}).`, + ), + ); + }); + }); + +const sha256 = (contents: Buffer | string) => createHash("sha256").update(contents).digest("hex"); + +const fileEvidence = async (filePath: string, expectedContents?: string) => { + const expected = + expectedContents === undefined + ? {} + : { + expectedSha256: sha256(expectedContents), + expectedSizeBytes: Buffer.byteLength(expectedContents), + }; + + try { + const contents = await readFile(filePath); + const { mtime, size } = await stat(filePath); + + return { + ...expected, + modifiedAt: mtime.toISOString(), + path: filePath, + sha256: sha256(contents), + sizeBytes: size, + }; + } catch (error) { + return { ...expected, error: String(error), path: filePath }; + } +}; + +const isPortFree = () => + new Promise((resolve) => { + const server = createServer(); + + server.once("error", () => resolve(false)); + server.once("listening", () => server.close(() => resolve(true))); + server.listen(WEBDRIVER_PORT, "127.0.0.1"); + }); + +// The embedded driver releases the port asynchronously as it shuts down. +const waitForPortRelease = async (timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + + while (!(await isPortFree())) { + if (Date.now() > deadline) { + return false; + } + + await delay(250); + } + + return true; +}; + +const main = async () => { + if (!process.env.APPDATA) { + throw new Error("APPDATA is required for the Windows-local desktop E2E suite."); + } + + await mkdir(artifactsRoot, { recursive: true }); + + const temporaryRoot = await mkdtemp(path.join(tmpdir(), "leafdown-desktop-e2e-")); + const documentPath = path.join(temporaryRoot, "document-lifecycle.md"); + const folderPath = path.join(temporaryRoot, "folder-context"); + const initialFolderFileName = "readme.md"; + const initialFolderFilePath = path.join(folderPath, initialFolderFileName); + const addedFolderFileName = "watcher-added.md"; + const addedFolderFilePath = path.join(folderPath, addedFolderFileName); + const missingDocumentPath = path.join(temporaryRoot, "missing-document.md"); + const savedMarker = "Saved fixture marker."; + const context: DesktopE2ERunContext = { + document: { + initialMarker: "Initial fixture marker.", + path: documentPath, + savedMarkdown: `${savedMarker}\n`, + savedMarker, + }, + folder: { + addedFileName: addedFolderFileName, + addedFilePath: addedFolderFilePath, + addedMarker: "Watcher-added fixture marker.", + initialFileName: initialFolderFileName, + initialFilePath: initialFolderFilePath, + initialMarker: "Folder index fixture marker.", + path: folderPath, + }, + missingDocumentPath, + settingsPath, + temporaryRoot, + }; + + await mkdir(folderPath, { recursive: true }); + await copyFile( + path.join(repositoryRoot, "e2e", "desktop", "fixtures", "document-lifecycle.md"), + documentPath, + ); + await copyFile( + path.join(repositoryRoot, "e2e", "desktop", "fixtures", "folder-context", "readme.md"), + initialFolderFilePath, + ); + await writeJson(contextPath, context); + + const scenarios: Scenario[] = [ + { name: "diagnostics" }, + { name: "document-lifecycle", recentFiles: [documentPath] }, + { name: "folder-watcher", recentFolders: [folderPath] }, + { name: "missing-document-error", recentFiles: [missingDocumentPath] }, + { name: "persistence-write" }, + { name: "persistence-restart", continues: "persistence-write" }, + { name: "window-lifecycle" }, + ]; + + try { + for (const [index, scenario] of scenarios.entries()) { + if (scenario.continues) { + if (scenario.continues !== scenarios[index - 1]?.name) { + throw new Error( + `Scenario ${scenario.name} must run directly after ${scenario.continues}.`, + ); + } + } else { + await resetPersistedState(scenario.recentFiles, scenario.recentFolders); + } + + await runWdio(scenario); + + if (!(await waitForPortRelease())) { + throw new Error( + `Scenario ${scenario.name} left a listener on port ${WEBDRIVER_PORT}. Stop it before the next run.`, + ); + } + } + } catch (error) { + await writeJson(path.join(artifactsRoot, "fixture-manifest.json"), { + document: await fileEvidence(documentPath, context.document.savedMarkdown), + failedAt: new Date().toISOString(), + folderAddedDocument: await fileEvidence( + addedFolderFilePath, + `${context.folder.addedMarker}\n`, + ), + folderInitialDocument: await fileEvidence( + initialFolderFilePath, + `${context.folder.initialMarker}\n`, + ), + missingDocument: await fileEvidence(missingDocumentPath), + temporaryRoot, + }); + + throw error; + } finally { + await Promise.all([ + rm(temporaryRoot, { force: true, recursive: true }), + rm(e2eAppDataDirectory, { force: true, recursive: true }), + ]); + } +}; + +await main(); diff --git a/e2e/desktop/specs/diagnostics.e2e.ts b/e2e/desktop/specs/diagnostics.e2e.ts index d77a14b..f848a1e 100644 --- a/e2e/desktop/specs/diagnostics.e2e.ts +++ b/e2e/desktop/specs/diagnostics.e2e.ts @@ -1,15 +1,11 @@ import { $, browser, expect } from "@wdio/globals"; -interface DiagnosticsSummary { - appIdentifier: string; - runId: string; -} +import { getDiagnosticsSummary } from "../support/diagnostics.js"; +import { openMenu } from "../support/ui.js"; describe("desktop diagnostics", () => { it("opens Diagnostics through Help and corroborates the summary through real IPC", async () => { - const helpMenu = $("aria/Help"); - await helpMenu.click(); - await browser.keys("Enter"); + await openMenu("Help"); await $("aria/Diagnostics...").click(); const dialog = $("aria/Diagnostics"); @@ -18,9 +14,7 @@ describe("desktop diagnostics", () => { const summaryField = $("aria/Diagnostics summary"); await expect(summaryField).toHaveValue(expect.stringContaining("Leafdown diagnostics")); - const summary = await browser.tauri.execute( - ({ core }) => core.invoke("get_diagnostics_summary") as Promise, - ); + const summary = await getDiagnosticsSummary(); const summaryText = await summaryField.getValue(); expect(summary.appIdentifier).toBe("com.azganoth.leafdown.e2e"); diff --git a/e2e/desktop/specs/document-lifecycle.e2e.ts b/e2e/desktop/specs/document-lifecycle.e2e.ts new file mode 100644 index 0000000..ddf38cc --- /dev/null +++ b/e2e/desktop/specs/document-lifecycle.e2e.ts @@ -0,0 +1,56 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readFile } from "node:fs/promises"; +import { Key } from "webdriverio"; + +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { getSaveMenuItem, openRecentPath, selectFileMenuItem } from "../support/ui.js"; + +describe("desktop document lifecycle", () => { + it("opens, edits, saves, and reopens a fixture through real IPC", async () => { + const { document } = await getDesktopE2ERunContext(); + + await openRecentPath(document.path); + + const editor = $('[contenteditable="true"]'); + await expect(editor).toBeDisplayed(); + await expect(editor).toHaveText(expect.stringContaining(document.initialMarker)); + + const cleanSaveItem = await getSaveMenuItem(); + await expect(cleanSaveItem).toHaveAttribute("data-disabled"); + await browser.keys("Escape"); + + await editor.click(); + await browser.keys([Key.Ctrl, "a"]); + await browser.keys(Key.NULL); + await editor.addValue(document.savedMarker); + + const dirtySaveItem = await getSaveMenuItem(); + await expect(dirtySaveItem).not.toHaveAttribute("data-disabled"); + await browser.keys("Escape"); + + await editor.click(); + await browser.keys([Key.Ctrl, "s", Key.NULL]); + await expect($('[data-slot="toast"][data-type="success"]')).toHaveText( + expect.stringContaining("Document saved."), + ); + + await browser.waitUntil( + async () => (await readFile(document.path, "utf8")) === document.savedMarkdown, + { + timeoutMsg: "The saved fixture did not reach the expected on-disk contents.", + }, + ); + + const savedSaveItem = await getSaveMenuItem(); + await expect(savedSaveItem).toHaveAttribute("data-disabled"); + await browser.keys("Escape"); + + await selectFileMenuItem("Close document"); + await expect($('[contenteditable="true"]')).not.toExist(); + + await openRecentPath(document.path); + await expect($('[contenteditable="true"]')).toHaveText( + expect.stringContaining(document.savedMarker), + ); + }); +}); diff --git a/e2e/desktop/specs/folder-watcher.e2e.ts b/e2e/desktop/specs/folder-watcher.e2e.ts new file mode 100644 index 0000000..6740e29 --- /dev/null +++ b/e2e/desktop/specs/folder-watcher.e2e.ts @@ -0,0 +1,39 @@ +import { $, expect } from "@wdio/globals"; +import { writeFile } from "node:fs/promises"; + +import { waitForDiagnosticRecord } from "../support/diagnostics.js"; +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { findTreeItem, openRecentPath } from "../support/ui.js"; + +describe("desktop folder context watcher", () => { + it("refreshes article navigation after an external filesystem change", async () => { + const { folder } = await getDesktopE2ERunContext(); + + await openRecentPath(folder.path); + + const initialArticle = await findTreeItem(folder.initialFileName); + await expect(initialArticle).toBeDisplayed(); + await expect(initialArticle).toHaveAttribute("aria-selected", "true"); + await expect($('[contenteditable="true"]')).toHaveText( + expect.stringContaining(folder.initialMarker), + ); + + await waitForDiagnosticRecord( + (record) => + record.event === "operationLifecycle" && + record.feature === "folder-context" && + record.operation === "folderContextWatcher" && + record.phase === "started", + ); + + await writeFile(folder.addedFilePath, `${folder.addedMarker}\n`); + + const addedArticle = await findTreeItem(folder.addedFileName); + await expect(addedArticle).toBeDisplayed(); + await addedArticle.click(); + await expect(addedArticle).toHaveAttribute("aria-selected", "true"); + await expect($('[contenteditable="true"]')).toHaveText( + expect.stringContaining(folder.addedMarker), + ); + }); +}); diff --git a/e2e/desktop/specs/missing-document-error.e2e.ts b/e2e/desktop/specs/missing-document-error.e2e.ts new file mode 100644 index 0000000..0cc876f --- /dev/null +++ b/e2e/desktop/specs/missing-document-error.e2e.ts @@ -0,0 +1,26 @@ +import { $, expect } from "@wdio/globals"; + +import { waitForDiagnosticRecord } from "../support/diagnostics.js"; +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { openRecentPath } from "../support/ui.js"; + +describe("desktop backend error propagation", () => { + it("surfaces a real missing-file IPC error with structured diagnostics", async () => { + const { missingDocumentPath } = await getDesktopE2ERunContext(); + + await openRecentPath(missingDocumentPath); + + const toast = $('[data-slot="toast"][data-type="error"]'); + await expect(toast).toHaveText(expect.stringContaining("Markdown file not found.")); + await expect(toast).toHaveText(expect.stringContaining(missingDocumentPath)); + + await waitForDiagnosticRecord( + (record) => + record.event === "operationFailed" && + record.feature === "document" && + record.operation === "openMarkdownDocument" && + record.errorKind === "missingFile" && + record.path === missingDocumentPath, + ); + }); +}); diff --git a/e2e/desktop/specs/persistence-restart.e2e.ts b/e2e/desktop/specs/persistence-restart.e2e.ts new file mode 100644 index 0000000..ce2bb45 --- /dev/null +++ b/e2e/desktop/specs/persistence-restart.e2e.ts @@ -0,0 +1,23 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readFile } from "node:fs/promises"; + +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { findMenuItem, openMenu } from "../support/ui.js"; + +describe("desktop persistence after restart", () => { + it("restores the sidebar setting in a fresh packaged-app process", async () => { + const { settingsPath } = await getDesktopE2ERunContext(); + + await openMenu("View"); + const sidebarItem = await findMenuItem((text) => text.startsWith("Toggle sidebar")); + await expect(sidebarItem).toHaveAttribute("aria-checked", "false"); + await browser.keys("Escape"); + await expect($("aria/Article navigator")).not.toExist(); + + const persistedSettings = JSON.parse(await readFile(settingsPath, "utf8")) as Record< + string, + unknown + >; + expect(persistedSettings.sidebarVisible).toBe(false); + }); +}); diff --git a/e2e/desktop/specs/persistence-write.e2e.ts b/e2e/desktop/specs/persistence-write.e2e.ts new file mode 100644 index 0000000..0b92cd9 --- /dev/null +++ b/e2e/desktop/specs/persistence-write.e2e.ts @@ -0,0 +1,33 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readFile } from "node:fs/promises"; + +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { findMenuItem, openMenu } from "../support/ui.js"; + +describe("desktop persistence before restart", () => { + it("changes the sidebar setting through the assembled menu and persists it", async () => { + const { settingsPath } = await getDesktopE2ERunContext(); + + await openMenu("View"); + const sidebarItem = await findMenuItem((text) => text.startsWith("Toggle sidebar")); + await expect(sidebarItem).toHaveAttribute("aria-checked", "true"); + await sidebarItem.click(); + + await expect($("aria/Article navigator")).not.toExist(); + + await browser.waitUntil( + async () => { + try { + const persisted = JSON.parse(await readFile(settingsPath, "utf8")) as Record< + string, + unknown + >; + return persisted.sidebarVisible === false; + } catch { + return false; + } + }, + { timeoutMsg: "The sidebar setting was not persisted before restart." }, + ); + }); +}); diff --git a/e2e/desktop/specs/window-lifecycle.e2e.ts b/e2e/desktop/specs/window-lifecycle.e2e.ts new file mode 100644 index 0000000..a4cb8f4 --- /dev/null +++ b/e2e/desktop/specs/window-lifecycle.e2e.ts @@ -0,0 +1,75 @@ +import { $, $$, browser, expect } from "@wdio/globals"; +import { setTimeout as delay } from "node:timers/promises"; + +import { getDiagnosticsSummary, readRunDiagnostics } from "../support/diagnostics.js"; + +const WINDOW_CONTROL_LABELS = ["Minimize window", "Maximize window", "Close window"]; + +const waitForNodeCondition = async ( + predicate: () => boolean | Promise, + timeoutMessage: string, +) => { + const deadline = Date.now() + 10_000; + + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + + await delay(100); + } + + throw new Error(timeoutMessage); +}; + +const isProcessRunning = (processId: number) => { + try { + process.kill(processId, 0); + return true; + } catch { + return false; + } +}; + +describe("desktop window lifecycle", () => { + it("exposes injected frame controls and exits through the real close handshake", async () => { + const diagnostics = await getDiagnosticsSummary(); + const processId = Number(diagnostics.runId.slice(diagnostics.runId.lastIndexOf("-") + 1)); + + expect(processId).toBeGreaterThan(0); + + await browser.waitUntil( + async () => (await $$("[data-tauri-frame-tb] > button").getElements()).length === 3, + { timeoutMsg: "The native frame controls were not injected." }, + ); + + const controls = await $$("[data-tauri-frame-tb] > button").getElements(); + expect(await controls.map((control) => control.getAttribute("aria-label"))).toEqual( + WINDOW_CONTROL_LABELS, + ); + + for (const control of controls) { + await expect(control).toHaveAttribute("tabindex", "-1"); + } + + const closeControl = $("aria/Close window"); + await closeControl.click(); + + await waitForNodeCondition( + () => !isProcessRunning(processId), + `The packaged application process ${processId} did not exit after Close window.`, + ); + + await waitForNodeCondition( + async () => + (await readRunDiagnostics(diagnostics)).some( + (record) => + record.event === "operationLifecycle" && + record.feature === "app" && + record.operation === "window" && + record.phase === "closing", + ), + "The clean window-closing diagnostic was not flushed before process exit.", + ); + }); +}); diff --git a/e2e/desktop/support/artifacts.ts b/e2e/desktop/support/artifacts.ts index 6317fdd..5b9814c 100644 --- a/e2e/desktop/support/artifacts.ts +++ b/e2e/desktop/support/artifacts.ts @@ -3,28 +3,20 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -interface DiagnosticsSummary { - appIdentifier: string; - appName: string; - appVersion: string; - architecture: string; - logDirectoryPath: string; - logFileCount: number; - logFileName: string; - logFilePath: string; - logMaxFileSizeBytes: number; - operatingSystem: string; - runId: string; -} +import type { DiagnosticsSummary } from "./diagnostics.js"; +import { RUN_LABEL } from "./suite.js"; const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url)); -const runLabel = - process.env.LEAFDOWN_E2E_ARTIFACT_RUN ?? - `${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`; +const scenarioLabel = process.env.LEAFDOWN_E2E_SCENARIO; -process.env.LEAFDOWN_E2E_ARTIFACT_RUN = runLabel; - -export const ARTIFACTS_DIR = path.join(repositoryRoot, "e2e", "desktop", "artifacts", runLabel); +export const ARTIFACTS_DIR = path.join( + repositoryRoot, + "e2e", + "desktop", + "artifacts", + RUN_LABEL, + ...(scenarioLabel ? [scenarioLabel] : []), +); const writeJson = async (fileName: string, value: unknown) => { await writeFile(path.join(ARTIFACTS_DIR, fileName), `${JSON.stringify(value, null, 2)}\n`); diff --git a/e2e/desktop/support/diagnostics.ts b/e2e/desktop/support/diagnostics.ts new file mode 100644 index 0000000..09be04d --- /dev/null +++ b/e2e/desktop/support/diagnostics.ts @@ -0,0 +1,63 @@ +import { browser } from "@wdio/globals"; +import { readFile } from "node:fs/promises"; + +export interface DiagnosticsSummary { + appIdentifier: string; + appName: string; + appVersion: string; + architecture: string; + logDirectoryPath: string; + logFileCount: number; + logFileName: string; + logFilePath: string; + logMaxFileSizeBytes: number; + operatingSystem: string; + runId: string; +} + +export interface DiagnosticRecord { + errorKind?: string; + event?: string; + feature?: string; + operation?: string; + path?: string; + phase?: string; + runId?: string; + [key: string]: unknown; +} + +export const getDiagnosticsSummary = () => + browser.tauri.execute( + ({ core }) => core.invoke("get_diagnostics_summary") as Promise, + ); + +export const readRunDiagnostics = async ({ logFilePath, runId }: DiagnosticsSummary) => { + const contents = await readFile(logFilePath, "utf8"); + + return contents + .split(/\r?\n/u) + .flatMap((line) => { + try { + return [JSON.parse(line) as DiagnosticRecord]; + } catch { + return []; + } + }) + .filter((record) => record.runId === runId); +}; + +export const waitForDiagnosticRecord = async (predicate: (record: DiagnosticRecord) => boolean) => { + const summary = await getDiagnosticsSummary(); + const result: { record?: DiagnosticRecord } = {}; + + await browser.waitUntil( + async () => { + const records = await readRunDiagnostics(summary); + result.record = records.find(predicate); + return Boolean(result.record); + }, + { timeoutMsg: "Expected structured application diagnostic did not appear." }, + ); + + return result.record!; +}; diff --git a/e2e/desktop/support/runContext.ts b/e2e/desktop/support/runContext.ts new file mode 100644 index 0000000..8921bb8 --- /dev/null +++ b/e2e/desktop/support/runContext.ts @@ -0,0 +1,40 @@ +import { readFile } from "node:fs/promises"; + +export interface DesktopE2ERunContext { + document: { + initialMarker: string; + path: string; + savedMarkdown: string; + savedMarker: string; + }; + folder: { + addedFileName: string; + addedFilePath: string; + addedMarker: string; + initialFileName: string; + initialFilePath: string; + initialMarker: string; + path: string; + }; + missingDocumentPath: string; + settingsPath: string; + temporaryRoot: string; +} + +let cachedRunContext: DesktopE2ERunContext | null = null; + +export const getDesktopE2ERunContext = async () => { + if (cachedRunContext) { + return cachedRunContext; + } + + const contextPath = process.env.LEAFDOWN_E2E_CONTEXT_PATH; + + if (!contextPath) { + throw new Error("LEAFDOWN_E2E_CONTEXT_PATH is required for fixture-backed desktop E2E tests."); + } + + cachedRunContext = JSON.parse(await readFile(contextPath, "utf8")) as DesktopE2ERunContext; + + return cachedRunContext; +}; diff --git a/e2e/desktop/support/suite.ts b/e2e/desktop/support/suite.ts new file mode 100644 index 0000000..a9781a7 --- /dev/null +++ b/e2e/desktop/support/suite.ts @@ -0,0 +1,5 @@ +export const RUN_LABEL = + process.env.LEAFDOWN_E2E_ARTIFACT_RUN ?? + `${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`; + +export const WEBDRIVER_PORT = 4445; diff --git a/e2e/desktop/support/ui.ts b/e2e/desktop/support/ui.ts new file mode 100644 index 0000000..37e7ecb --- /dev/null +++ b/e2e/desktop/support/ui.ts @@ -0,0 +1,54 @@ +import { $, $$, browser } from "@wdio/globals"; + +const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'; + +const findByText = async ( + selector: string, + predicate: (text: string) => boolean, + timeoutMsg: string, +) => { + const result: { item?: WebdriverIO.Element } = {}; + + await browser.waitUntil( + async () => { + for (const item of await $$(selector).getElements()) { + if (predicate((await item.getText()).trim())) { + result.item = item; + return true; + } + } + + return false; + }, + { timeoutMsg }, + ); + + return result.item!; +}; + +export const openMenu = async (label: string) => { + await $(`aria/${label}`).click(); +}; + +export const findMenuItem = (predicate: (text: string) => boolean) => + findByText(MENU_ITEM_SELECTOR, predicate, "Expected menu item did not appear."); + +export const findTreeItem = (label: string) => + findByText('[role="treeitem"]', (text) => text === label, `Tree item ${label} did not appear.`); + +export const openRecentPath = async (path: string) => { + await openMenu("File"); + const openRecent = await findMenuItem((text) => text === "Open recent"); + await openRecent.click(); + await (await findMenuItem((text) => text === path)).click(); +}; + +export const getSaveMenuItem = async () => { + await openMenu("File"); + return findMenuItem((text) => text.startsWith("Save") && !text.startsWith("Save as")); +}; + +export const selectFileMenuItem = async (label: string) => { + await openMenu("File"); + await (await findMenuItem((text) => text.startsWith(label))).click(); +}; diff --git a/e2e/desktop/wdio.conf.ts b/e2e/desktop/wdio.conf.ts index 41c30d9..2c09f1e 100644 --- a/e2e/desktop/wdio.conf.ts +++ b/e2e/desktop/wdio.conf.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { ARTIFACTS_DIR, captureFailureArtifacts } from "./support/artifacts.js"; +import { WEBDRIVER_PORT } from "./support/suite.js"; const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); const appBinaryPath = path.join( @@ -13,7 +14,13 @@ const appBinaryPath = path.join( "debug", "leafdown-e2e.exe", ); -const webdriverPort = 4445; +const requestedSpec = process.env.LEAFDOWN_E2E_SPEC; + +if (!requestedSpec) { + throw new Error("LEAFDOWN_E2E_SPEC is required. Run the suite with pnpm test:e2e:desktop."); +} + +let activeBrowser: WebdriverIO.Browser | undefined; const capabilities: TauriCapabilities[] = [ { @@ -26,7 +33,7 @@ const capabilities: TauriCapabilities[] = [ export const config: WebdriverIO.Config = { runner: "local", - specs: [path.join(repositoryRoot, "e2e", "desktop", "specs", "diagnostics.e2e.ts")], + specs: [path.resolve(repositoryRoot, requestedSpec)], maxInstances: 1, capabilities, services: [ @@ -35,7 +42,7 @@ export const config: WebdriverIO.Config = { { appBinaryPath, driverProvider: "embedded", - embeddedPort: webdriverPort, + embeddedPort: WEBDRIVER_PORT, captureBackendLogs: true, captureFrontendLogs: true, backendLogLevel: "info", @@ -55,8 +62,20 @@ export const config: WebdriverIO.Config = { timeout: 60_000, }, before: async (_capabilities, _specs, browser: WebdriverIO.Browser) => { + activeBrowser = browser; await browser.setWindowSize(1024, 768); }, + after: async () => { + if (!activeBrowser) { + return; + } + + try { + await activeBrowser.getTitle(); + } catch { + activeBrowser.sessionId = ""; + } + }, afterTest: async (_test, _context, { error, passed }) => { if (!passed) { try { diff --git a/package.json b/package.json index 43e4720..b0ebe4f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:frontend": "tsc -b && vite build", "build:frontend:e2e": "tsc -b && vite build --mode desktop-e2e", "build:e2e:desktop": "tauri build --debug --no-bundle --features desktop-e2e --config e2e/desktop/tauri.conf.json -- --target-dir target/desktop-e2e", - "test:e2e:desktop": "pnpm build:e2e:desktop && wdio run e2e/desktop/wdio.conf.ts", + "test:e2e:desktop": "pnpm build:e2e:desktop && tsx e2e/desktop/run.ts", "preview": "vite preview", "tauri": "tauri", "lint": "pnpm lint:frontend && pnpm lint:backend",