From 754be6594adddeabedab2e4e2f2bf66d153bf808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Wed, 5 Aug 2026 04:04:36 -0300 Subject: [PATCH 01/11] Cover the assembled document lifecycle --- e2e/desktop/fixtures/document-lifecycle.md | 1 + e2e/desktop/run.ts | 164 ++++++++++++++++++++ e2e/desktop/specs/document-lifecycle.e2e.ts | 53 +++++++ e2e/desktop/support/artifacts.ts | 10 +- e2e/desktop/support/runContext.ts | 29 ++++ e2e/desktop/support/ui.ts | 53 +++++++ e2e/desktop/wdio.conf.ts | 7 +- package.json | 2 +- 8 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 e2e/desktop/fixtures/document-lifecycle.md create mode 100644 e2e/desktop/run.ts create mode 100644 e2e/desktop/specs/document-lifecycle.e2e.ts create mode 100644 e2e/desktop/support/runContext.ts create mode 100644 e2e/desktop/support/ui.ts 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/run.ts b/e2e/desktop/run.ts new file mode 100644 index 0000000..92f035a --- /dev/null +++ b/e2e/desktop/run.ts @@ -0,0 +1,164 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { DesktopE2ERunContext } from "./support/runContext.js"; + +interface Scenario { + name: string; + prepareState: (context: DesktopE2ERunContext) => Promise; + spec: string; +} + +const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); +const runLabel = `${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`; +const artifactsRoot = path.join(repositoryRoot, "e2e", "desktop", "artifacts", runLabel); +const contextPath = path.join(artifactsRoot, "run-context.json"); +const e2eStoreDirectory = path.join( + process.env.APPDATA ?? "", + "com.azganoth.leafdown.e2e", + "tauri-plugin-zustand", +); +const recentItemsPath = path.join(e2eStoreDirectory, "recent-items.dev.json"); +const settingsPath = path.join(e2eStoreDirectory, "settings.dev.json"); + +const initialSettings = { + articleSortOrder: "name", + autoPairBracketsAndQuotes: true, + defaultNewDocumentExtension: ".md", + defaultNewDocumentLineEnding: "crlf", + ignoredDirectories: [".git", ".hg", ".svn", "node_modules", "target", "dist", "build", ".cache"], + indexFileNames: ["readme", "index"], + insertFinalNewline: true, + recordRecentItems: true, + sidebarVisible: true, + softWrapCodeBlocks: false, + theme: "system", + version: 1, +}; + +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 Promise.all([ + writeJson(recentItemsPath, { recentFiles, recentFolders, version: 1 }), + writeJson(settingsPath, initialSettings), + ]); +}; + +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: runLabel, + LEAFDOWN_E2E_CONTEXT_PATH: contextPath, + LEAFDOWN_E2E_SCENARIO: scenario.name, + LEAFDOWN_E2E_SPEC: scenario.spec, + }, + 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 fileEvidence = async (filePath: string) => { + try { + const contents = await readFile(filePath); + const metadata = await stat(filePath); + + return { + path: filePath, + sha256: createHash("sha256").update(contents).digest("hex"), + sizeBytes: metadata.size, + }; + } catch (error) { + return { error: String(error), path: filePath }; + } +}; + +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 savedMarker = "Saved fixture marker."; + const context: DesktopE2ERunContext = { + document: { + initialMarker: "Initial fixture marker.", + path: documentPath, + savedMarkdown: `${savedMarker}\n`, + savedMarker, + }, + temporaryRoot, + }; + + await copyFile( + path.join(repositoryRoot, "e2e", "desktop", "fixtures", "document-lifecycle.md"), + documentPath, + ); + await writeJson(contextPath, context); + + const scenarios: Scenario[] = [ + { + name: "diagnostics", + prepareState: () => resetPersistedState(), + spec: "e2e/desktop/specs/diagnostics.e2e.ts", + }, + { + name: "document-lifecycle", + prepareState: ({ document }) => resetPersistedState([document.path]), + spec: "e2e/desktop/specs/document-lifecycle.e2e.ts", + }, + ]; + + try { + for (const scenario of scenarios) { + await scenario.prepareState(context); + await runWdio(scenario); + } + } finally { + await writeJson(path.join(artifactsRoot, "fixture-manifest.json"), { + document: await fileEvidence(documentPath), + temporaryRoot, + }); + await Promise.all([ + rm(temporaryRoot, { force: true, recursive: true }), + rm(e2eStoreDirectory, { force: true, recursive: true }), + ]); + } +}; + +await main(); diff --git a/e2e/desktop/specs/document-lifecycle.e2e.ts b/e2e/desktop/specs/document-lifecycle.e2e.ts new file mode 100644 index 0000000..fac9170 --- /dev/null +++ b/e2e/desktop/specs/document-lifecycle.e2e.ts @@ -0,0 +1,53 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readFile } from "node:fs/promises"; + +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(["Control", "a"]); + await editor.addValue(document.savedMarker); + + const dirtySaveItem = await getSaveMenuItem(); + await expect(dirtySaveItem).not.toHaveAttribute("data-disabled"); + await browser.keys("Escape"); + + await browser.keys(["Control", "s"]); + await expect($('[data-sonner-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/support/artifacts.ts b/e2e/desktop/support/artifacts.ts index 6317fdd..6f4b61c 100644 --- a/e2e/desktop/support/artifacts.ts +++ b/e2e/desktop/support/artifacts.ts @@ -21,10 +21,18 @@ 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", + runLabel, + ...(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/runContext.ts b/e2e/desktop/support/runContext.ts new file mode 100644 index 0000000..c655d6d --- /dev/null +++ b/e2e/desktop/support/runContext.ts @@ -0,0 +1,29 @@ +import { readFile } from "node:fs/promises"; + +export interface DesktopE2ERunContext { + document: { + initialMarker: string; + path: string; + savedMarkdown: string; + savedMarker: 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/ui.ts b/e2e/desktop/support/ui.ts new file mode 100644 index 0000000..72a2989 --- /dev/null +++ b/e2e/desktop/support/ui.ts @@ -0,0 +1,53 @@ +import { $, $$, browser } from "@wdio/globals"; + +type MenuItemPredicate = (text: string) => boolean; + +export const openMenu = async (label: string) => { + await $(`aria/${label}`).click(); + await browser.keys("Enter"); +}; + +export const findMenuItem = async (predicate: MenuItemPredicate) => { + const result: { item?: WebdriverIO.Element } = {}; + + await browser.waitUntil( + async () => { + const items = await $$( + '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]', + ); + + for (const item of items) { + if (predicate((await item.getText()).trim())) { + result.item = item; + return true; + } + } + + return false; + }, + { timeoutMsg: "Expected menu item did not appear." }, + ); + + if (!result.item) { + throw new Error("Expected menu item did not appear."); + } + + return result.item; +}; + +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..4758a19 100644 --- a/e2e/desktop/wdio.conf.ts +++ b/e2e/desktop/wdio.conf.ts @@ -14,6 +14,7 @@ const appBinaryPath = path.join( "leafdown-e2e.exe", ); const webdriverPort = 4445; +const requestedSpec = process.env.LEAFDOWN_E2E_SPEC; const capabilities: TauriCapabilities[] = [ { @@ -26,7 +27,11 @@ const capabilities: TauriCapabilities[] = [ export const config: WebdriverIO.Config = { runner: "local", - specs: [path.join(repositoryRoot, "e2e", "desktop", "specs", "diagnostics.e2e.ts")], + specs: [ + requestedSpec + ? path.resolve(repositoryRoot, requestedSpec) + : path.join(repositoryRoot, "e2e", "desktop", "specs", "diagnostics.e2e.ts"), + ], maxInstances: 1, capabilities, services: [ 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", From 2f5c2b22dfb031c23dff79c414ceb8d6a7a1e131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Wed, 5 Aug 2026 04:14:19 -0300 Subject: [PATCH 02/11] Cover folder refresh and IPC errors --- e2e/desktop/fixtures/folder-context/readme.md | 1 + e2e/desktop/run.ts | 37 ++++++++++ e2e/desktop/specs/document-lifecycle.e2e.ts | 7 +- e2e/desktop/specs/folder-watcher.e2e.ts | 40 +++++++++++ .../specs/missing-document-error.e2e.ts | 27 +++++++ e2e/desktop/support/diagnostics.ts | 70 +++++++++++++++++++ e2e/desktop/support/runContext.ts | 10 +++ e2e/desktop/support/ui.ts | 26 +++++++ 8 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 e2e/desktop/fixtures/folder-context/readme.md create mode 100644 e2e/desktop/specs/folder-watcher.e2e.ts create mode 100644 e2e/desktop/specs/missing-document-error.e2e.ts create mode 100644 e2e/desktop/support/diagnostics.ts 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 index 92f035a..86b0510 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -114,6 +114,12 @@ const main = async () => { 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: { @@ -122,13 +128,28 @@ const main = async () => { 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, 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[] = [ @@ -142,6 +163,16 @@ const main = async () => { prepareState: ({ document }) => resetPersistedState([document.path]), spec: "e2e/desktop/specs/document-lifecycle.e2e.ts", }, + { + name: "folder-watcher", + prepareState: ({ folder }) => resetPersistedState([], [folder.path]), + spec: "e2e/desktop/specs/folder-watcher.e2e.ts", + }, + { + name: "missing-document-error", + prepareState: ({ missingDocumentPath }) => resetPersistedState([missingDocumentPath]), + spec: "e2e/desktop/specs/missing-document-error.e2e.ts", + }, ]; try { @@ -152,6 +183,12 @@ const main = async () => { } finally { await writeJson(path.join(artifactsRoot, "fixture-manifest.json"), { document: await fileEvidence(documentPath), + folder: { + addedDocument: await fileEvidence(addedFolderFilePath), + initialDocument: await fileEvidence(initialFolderFilePath), + path: folderPath, + }, + missingDocument: await fileEvidence(missingDocumentPath), temporaryRoot, }); await Promise.all([ diff --git a/e2e/desktop/specs/document-lifecycle.e2e.ts b/e2e/desktop/specs/document-lifecycle.e2e.ts index fac9170..d2469bc 100644 --- a/e2e/desktop/specs/document-lifecycle.e2e.ts +++ b/e2e/desktop/specs/document-lifecycle.e2e.ts @@ -1,5 +1,6 @@ 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"; @@ -19,14 +20,16 @@ describe("desktop document lifecycle", () => { await browser.keys("Escape"); await editor.click(); - await browser.keys(["Control", "a"]); + 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 browser.keys(["Control", "s"]); + await editor.click(); + await browser.keys([Key.Ctrl, "s", Key.NULL]); await expect($('[data-sonner-toast][data-type="success"]')).toHaveText( expect.stringContaining("Document saved."), ); diff --git a/e2e/desktop/specs/folder-watcher.e2e.ts b/e2e/desktop/specs/folder-watcher.e2e.ts new file mode 100644 index 0000000..b4988cd --- /dev/null +++ b/e2e/desktop/specs/folder-watcher.e2e.ts @@ -0,0 +1,40 @@ +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", + "watcher-started.json", + ); + + 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..009a2df --- /dev/null +++ b/e2e/desktop/specs/missing-document-error.e2e.ts @@ -0,0 +1,27 @@ +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-sonner-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, + "missing-document-diagnostic.json", + ); + }); +}); diff --git a/e2e/desktop/support/diagnostics.ts b/e2e/desktop/support/diagnostics.ts new file mode 100644 index 0000000..172cb1d --- /dev/null +++ b/e2e/desktop/support/diagnostics.ts @@ -0,0 +1,70 @@ +import { browser } from "@wdio/globals"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { ARTIFACTS_DIR } from "./artifacts.js"; + +interface DiagnosticsSummary { + logFilePath: 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, + ); + +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, + evidenceFileName: string, +) => { + 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." }, + ); + + if (!result.record) { + throw new Error("Expected structured application diagnostic did not appear."); + } + + await mkdir(ARTIFACTS_DIR, { recursive: true }); + await writeFile( + path.join(ARTIFACTS_DIR, evidenceFileName), + `${JSON.stringify(result.record, null, 2)}\n`, + ); + + return result.record; +}; diff --git a/e2e/desktop/support/runContext.ts b/e2e/desktop/support/runContext.ts index c655d6d..85addbf 100644 --- a/e2e/desktop/support/runContext.ts +++ b/e2e/desktop/support/runContext.ts @@ -7,6 +7,16 @@ export interface DesktopE2ERunContext { savedMarkdown: string; savedMarker: string; }; + folder: { + addedFileName: string; + addedFilePath: string; + addedMarker: string; + initialFileName: string; + initialFilePath: string; + initialMarker: string; + path: string; + }; + missingDocumentPath: string; temporaryRoot: string; } diff --git a/e2e/desktop/support/ui.ts b/e2e/desktop/support/ui.ts index 72a2989..973a28d 100644 --- a/e2e/desktop/support/ui.ts +++ b/e2e/desktop/support/ui.ts @@ -51,3 +51,29 @@ export const selectFileMenuItem = async (label: string) => { await openMenu("File"); await (await findMenuItem((text) => text.startsWith(label))).click(); }; + +export const findTreeItem = async (label: string) => { + const result: { item?: WebdriverIO.Element } = {}; + + await browser.waitUntil( + async () => { + const items = await $$('[role="treeitem"]'); + + for (const item of items) { + if ((await item.getText()).trim() === label) { + result.item = item; + return true; + } + } + + return false; + }, + { timeoutMsg: `Tree item ${label} did not appear.` }, + ); + + if (!result.item) { + throw new Error(`Tree item ${label} did not appear.`); + } + + return result.item; +}; From 787f2e6f3d00d039eeee5ff408a4693acd900475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Wed, 5 Aug 2026 04:17:55 -0300 Subject: [PATCH 03/11] Verify settings across a desktop restart --- e2e/desktop/run.ts | 12 +++++ e2e/desktop/specs/persistence-restart.e2e.ts | 49 ++++++++++++++++++++ e2e/desktop/specs/persistence-write.e2e.ts | 41 ++++++++++++++++ e2e/desktop/support/diagnostics.ts | 2 +- e2e/desktop/support/runContext.ts | 2 + 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 e2e/desktop/specs/persistence-restart.e2e.ts create mode 100644 e2e/desktop/specs/persistence-write.e2e.ts diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 86b0510..380fc25 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -138,6 +138,8 @@ const main = async () => { path: folderPath, }, missingDocumentPath, + persistenceEvidencePath: path.join(artifactsRoot, "persistence-phase-one.json"), + settingsPath, temporaryRoot, }; @@ -173,6 +175,16 @@ const main = async () => { prepareState: ({ missingDocumentPath }) => resetPersistedState([missingDocumentPath]), spec: "e2e/desktop/specs/missing-document-error.e2e.ts", }, + { + name: "persistence-write", + prepareState: () => resetPersistedState(), + spec: "e2e/desktop/specs/persistence-write.e2e.ts", + }, + { + name: "persistence-restart", + prepareState: () => Promise.resolve(), + spec: "e2e/desktop/specs/persistence-restart.e2e.ts", + }, ]; try { diff --git a/e2e/desktop/specs/persistence-restart.e2e.ts b/e2e/desktop/specs/persistence-restart.e2e.ts new file mode 100644 index 0000000..5f7335b --- /dev/null +++ b/e2e/desktop/specs/persistence-restart.e2e.ts @@ -0,0 +1,49 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { ARTIFACTS_DIR } from "../support/artifacts.js"; +import { getDiagnosticsSummary } from "../support/diagnostics.js"; +import { getDesktopE2ERunContext } from "../support/runContext.js"; +import { findMenuItem, openMenu } from "../support/ui.js"; + +interface PersistencePhaseOneEvidence { + firstRunId: string; +} + +describe("desktop persistence after restart", () => { + it("restores the sidebar setting in a fresh packaged-app process", async () => { + const { persistenceEvidencePath, settingsPath } = await getDesktopE2ERunContext(); + const phaseOne = JSON.parse( + await readFile(persistenceEvidencePath, "utf8"), + ) as PersistencePhaseOneEvidence; + const diagnostics = await getDiagnosticsSummary(); + + expect(diagnostics.runId).not.toBe(phaseOne.firstRunId); + + 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); + + await writeFile( + path.join(ARTIFACTS_DIR, "restart-evidence.json"), + `${JSON.stringify( + { + firstRunId: phaseOne.firstRunId, + persistedSidebarVisible: persistedSettings.sidebarVisible, + secondRunId: diagnostics.runId, + }, + null, + 2, + )}\n`, + ); + }); +}); diff --git a/e2e/desktop/specs/persistence-write.e2e.ts b/e2e/desktop/specs/persistence-write.e2e.ts new file mode 100644 index 0000000..5ae1434 --- /dev/null +++ b/e2e/desktop/specs/persistence-write.e2e.ts @@ -0,0 +1,41 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readFile, writeFile } from "node:fs/promises"; + +import { getDiagnosticsSummary } from "../support/diagnostics.js"; +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 { persistenceEvidencePath, settingsPath } = await getDesktopE2ERunContext(); + const diagnostics = await getDiagnosticsSummary(); + + 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(); + + let persistedSettings: Record | undefined; + await browser.waitUntil( + async () => { + try { + persistedSettings = JSON.parse(await readFile(settingsPath, "utf8")) as Record< + string, + unknown + >; + return persistedSettings.sidebarVisible === false; + } catch { + return false; + } + }, + { timeoutMsg: "The sidebar setting was not persisted before restart." }, + ); + + await writeFile( + persistenceEvidencePath, + `${JSON.stringify({ firstRunId: diagnostics.runId, persistedSettings }, null, 2)}\n`, + ); + }); +}); diff --git a/e2e/desktop/support/diagnostics.ts b/e2e/desktop/support/diagnostics.ts index 172cb1d..2a6a3f1 100644 --- a/e2e/desktop/support/diagnostics.ts +++ b/e2e/desktop/support/diagnostics.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { ARTIFACTS_DIR } from "./artifacts.js"; -interface DiagnosticsSummary { +export interface DiagnosticsSummary { logFilePath: string; runId: string; } diff --git a/e2e/desktop/support/runContext.ts b/e2e/desktop/support/runContext.ts index 85addbf..e1bf572 100644 --- a/e2e/desktop/support/runContext.ts +++ b/e2e/desktop/support/runContext.ts @@ -17,6 +17,8 @@ export interface DesktopE2ERunContext { path: string; }; missingDocumentPath: string; + persistenceEvidencePath: string; + settingsPath: string; temporaryRoot: string; } From d367567886aadb8b8cd41b33ec953a10fc3078d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Wed, 5 Aug 2026 04:24:52 -0300 Subject: [PATCH 04/11] Exercise the native window lifecycle --- e2e/desktop/run.ts | 5 + e2e/desktop/specs/window-lifecycle.e2e.ts | 111 ++++++++++++++++++++++ e2e/desktop/wdio.conf.ts | 10 ++ 3 files changed, 126 insertions(+) create mode 100644 e2e/desktop/specs/window-lifecycle.e2e.ts diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 380fc25..5ee4d76 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -185,6 +185,11 @@ const main = async () => { prepareState: () => Promise.resolve(), spec: "e2e/desktop/specs/persistence-restart.e2e.ts", }, + { + name: "window-lifecycle", + prepareState: () => resetPersistedState(), + spec: "e2e/desktop/specs/window-lifecycle.e2e.ts", + }, ]; try { diff --git a/e2e/desktop/specs/window-lifecycle.e2e.ts b/e2e/desktop/specs/window-lifecycle.e2e.ts new file mode 100644 index 0000000..69a5691 --- /dev/null +++ b/e2e/desktop/specs/window-lifecycle.e2e.ts @@ -0,0 +1,111 @@ +import { $, $$, browser, expect } from "@wdio/globals"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { ARTIFACTS_DIR } from "../support/artifacts.js"; +import { type DiagnosticRecord, getDiagnosticsSummary } 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 new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + + throw new Error(timeoutMessage); +}; + +const isProcessRunning = (processId: number) => { + try { + process.kill(processId, 0); + return true; + } catch { + return false; + } +}; + +const readClosingDiagnostic = async (logFilePath: string, runId: string) => { + const contents = await readFile(logFilePath, "utf8"); + + for (const line of contents.split(/\r?\n/u)) { + try { + const record = JSON.parse(line) as DiagnosticRecord; + + if ( + record.runId === runId && + record.event === "operationLifecycle" && + record.feature === "app" && + record.operation === "window" && + record.phase === "closing" + ) { + return record; + } + } catch { + // The app log can contain an incomplete final line while it is being flushed. + } + } + + return undefined; +}; + +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").length) === 3, { + timeoutMsg: "The native frame controls were not injected.", + }); + + const controls = await $$("[data-tauri-frame-tb] > button"); + 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.`, + ); + + let closingDiagnostic: DiagnosticRecord | undefined; + await waitForNodeCondition(async () => { + closingDiagnostic = await readClosingDiagnostic(diagnostics.logFilePath, diagnostics.runId); + return Boolean(closingDiagnostic); + }, "The clean window-closing diagnostic was not flushed before process exit."); + + await mkdir(ARTIFACTS_DIR, { recursive: true }); + await writeFile( + path.join(ARTIFACTS_DIR, "window-close-evidence.json"), + `${JSON.stringify( + { + closingDiagnostic, + controls: WINDOW_CONTROL_LABELS, + processExited: true, + processId, + }, + null, + 2, + )}\n`, + ); + }); +}); diff --git a/e2e/desktop/wdio.conf.ts b/e2e/desktop/wdio.conf.ts index 4758a19..f5367e3 100644 --- a/e2e/desktop/wdio.conf.ts +++ b/e2e/desktop/wdio.conf.ts @@ -15,6 +15,8 @@ const appBinaryPath = path.join( ); const webdriverPort = 4445; const requestedSpec = process.env.LEAFDOWN_E2E_SPEC; +const closesApplicationUnderTest = process.env.LEAFDOWN_E2E_SCENARIO === "window-lifecycle"; +let activeBrowser: WebdriverIO.Browser | undefined; const capabilities: TauriCapabilities[] = [ { @@ -60,8 +62,16 @@ export const config: WebdriverIO.Config = { timeout: 60_000, }, before: async (_capabilities, _specs, browser: WebdriverIO.Browser) => { + activeBrowser = browser; await browser.setWindowSize(1024, 768); }, + after: () => { + if (closesApplicationUnderTest && activeBrowser) { + // The terminal scenario already destroyed the embedded WebDriver with the app. + // Clear the local handle so WDIO does not issue a DELETE to a server that no longer exists. + activeBrowser.sessionId = ""; + } + }, afterTest: async (_test, _context, { error, passed }) => { if (!passed) { try { From 6769e62409d0a81d4be45b15481c2a83882722e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Wed, 5 Aug 2026 04:38:29 -0300 Subject: [PATCH 05/11] Document the expanded desktop E2E suite --- CONTRIBUTING.md | 10 +++++----- docs/architecture.md | 4 +++- e2e/desktop/specs/window-lifecycle.e2e.ts | 9 +++++---- e2e/desktop/support/ui.ts | 4 ++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73fc92e..8bf3a16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,17 +74,17 @@ 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 suite retains the Diagnostics smoke test and narrowly covers document open/edit/save/reopen, folder navigation and native watcher refresh, missing-file error propagation, settings persistence across a process restart, injected frame controls, and the clean window-close handshake. 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 seeds only the isolated E2E persisted store, creates temporary filesystem fixtures, and removes both after the suite. Each run writes shared fixture evidence under `e2e/desktop/artifacts//` and 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. 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, temporarily set `LEAFDOWN_E2E_FORCE_FAILURE=1` in the shell and run the suite. 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 +218,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..839ab81 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. The runner uses a dedicated application identifier, resets only that identifier's persisted store, creates temporary fixtures, and cleans both after the run. 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/specs/window-lifecycle.e2e.ts b/e2e/desktop/specs/window-lifecycle.e2e.ts index 69a5691..722d2ab 100644 --- a/e2e/desktop/specs/window-lifecycle.e2e.ts +++ b/e2e/desktop/specs/window-lifecycle.e2e.ts @@ -66,11 +66,12 @@ describe("desktop window lifecycle", () => { expect(processId).toBeGreaterThan(0); - await browser.waitUntil(async () => (await $$("[data-tauri-frame-tb] > button").length) === 3, { - timeoutMsg: "The native frame controls were not injected.", - }); + 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"); + const controls = await $$("[data-tauri-frame-tb] > button").getElements(); expect(await controls.map((control) => control.getAttribute("aria-label"))).toEqual( WINDOW_CONTROL_LABELS, ); diff --git a/e2e/desktop/support/ui.ts b/e2e/desktop/support/ui.ts index 973a28d..162b1bd 100644 --- a/e2e/desktop/support/ui.ts +++ b/e2e/desktop/support/ui.ts @@ -14,7 +14,7 @@ export const findMenuItem = async (predicate: MenuItemPredicate) => { async () => { const items = await $$( '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]', - ); + ).getElements(); for (const item of items) { if (predicate((await item.getText()).trim())) { @@ -57,7 +57,7 @@ export const findTreeItem = async (label: string) => { await browser.waitUntil( async () => { - const items = await $$('[role="treeitem"]'); + const items = await $$('[role="treeitem"]').getElements(); for (const item of items) { if ((await item.getText()).trim() === label) { From ad89cf6efccb413c81155bbc8d91ae29d677f881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 9 Aug 2026 03:28:56 -0300 Subject: [PATCH 06/11] Realign desktop E2E selectors with Base UI --- e2e/desktop/specs/diagnostics.e2e.ts | 6 +++--- e2e/desktop/specs/document-lifecycle.e2e.ts | 2 +- e2e/desktop/specs/missing-document-error.e2e.ts | 2 +- e2e/desktop/support/ui.ts | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/e2e/desktop/specs/diagnostics.e2e.ts b/e2e/desktop/specs/diagnostics.e2e.ts index d77a14b..01c6b5b 100644 --- a/e2e/desktop/specs/diagnostics.e2e.ts +++ b/e2e/desktop/specs/diagnostics.e2e.ts @@ -1,5 +1,7 @@ import { $, browser, expect } from "@wdio/globals"; +import { openMenu } from "../support/ui.js"; + interface DiagnosticsSummary { appIdentifier: string; runId: string; @@ -7,9 +9,7 @@ interface DiagnosticsSummary { 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"); diff --git a/e2e/desktop/specs/document-lifecycle.e2e.ts b/e2e/desktop/specs/document-lifecycle.e2e.ts index d2469bc..ddf38cc 100644 --- a/e2e/desktop/specs/document-lifecycle.e2e.ts +++ b/e2e/desktop/specs/document-lifecycle.e2e.ts @@ -30,7 +30,7 @@ describe("desktop document lifecycle", () => { await editor.click(); await browser.keys([Key.Ctrl, "s", Key.NULL]); - await expect($('[data-sonner-toast][data-type="success"]')).toHaveText( + await expect($('[data-slot="toast"][data-type="success"]')).toHaveText( expect.stringContaining("Document saved."), ); diff --git a/e2e/desktop/specs/missing-document-error.e2e.ts b/e2e/desktop/specs/missing-document-error.e2e.ts index 009a2df..22a4029 100644 --- a/e2e/desktop/specs/missing-document-error.e2e.ts +++ b/e2e/desktop/specs/missing-document-error.e2e.ts @@ -10,7 +10,7 @@ describe("desktop backend error propagation", () => { await openRecentPath(missingDocumentPath); - const toast = $('[data-sonner-toast][data-type="error"]'); + 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)); diff --git a/e2e/desktop/support/ui.ts b/e2e/desktop/support/ui.ts index 162b1bd..580b80d 100644 --- a/e2e/desktop/support/ui.ts +++ b/e2e/desktop/support/ui.ts @@ -4,7 +4,6 @@ type MenuItemPredicate = (text: string) => boolean; export const openMenu = async (label: string) => { await $(`aria/${label}`).click(); - await browser.keys("Enter"); }; export const findMenuItem = async (predicate: MenuItemPredicate) => { From b460e476db4e3b2504a26df53b62467f7537def7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 9 Aug 2026 03:59:11 -0300 Subject: [PATCH 07/11] Assert port release and widen fixture evidence --- e2e/desktop/run.ts | 102 ++++++++++++++++++++++++++++------- e2e/desktop/support/suite.ts | 1 + e2e/desktop/wdio.conf.ts | 4 +- 3 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 e2e/desktop/support/suite.ts diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 5ee4d76..2707864 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -1,11 +1,14 @@ 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 { WEBDRIVER_PORT } from "./support/suite.js"; interface Scenario { name: string; @@ -90,21 +93,57 @@ const runWdio = (scenario: Scenario) => }); }); -const fileEvidence = async (filePath: string) => { +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 metadata = await stat(filePath); return { + ...expected, + modifiedAt: metadata.mtime.toISOString(), path: filePath, - sha256: createHash("sha256").update(contents).digest("hex"), + sha256: sha256(contents), sizeBytes: metadata.size, }; } catch (error) { - return { error: String(error), path: filePath }; + return { ...expected, error: String(error), path: filePath }; } }; +const isPortFree = (port: number) => + new Promise((resolve) => { + const server = createServer(); + + server.once("error", () => resolve(false)); + server.once("listening", () => server.close(() => resolve(true))); + server.listen(port, "127.0.0.1"); + }); + +// The embedded driver releases the port asynchronously as it shuts down. +const waitForPortRelease = async (port: number, timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await isPortFree(port)) { + return true; + } + + await delay(250); + } + + return isPortFree(port); +}; + const main = async () => { if (!process.env.APPDATA) { throw new Error("APPDATA is required for the Windows-local desktop E2E suite."); @@ -192,26 +231,53 @@ const main = async () => { }, ]; + let scenarioError: unknown; + try { for (const scenario of scenarios) { await scenario.prepareState(context); await runWdio(scenario); } - } finally { - await writeJson(path.join(artifactsRoot, "fixture-manifest.json"), { - document: await fileEvidence(documentPath), - folder: { - addedDocument: await fileEvidence(addedFolderFilePath), - initialDocument: await fileEvidence(initialFolderFilePath), - path: folderPath, - }, - missingDocument: await fileEvidence(missingDocumentPath), - temporaryRoot, - }); - await Promise.all([ - rm(temporaryRoot, { force: true, recursive: true }), - rm(e2eStoreDirectory, { force: true, recursive: true }), - ]); + } catch (error) { + scenarioError = error; + } + + // Gather evidence before the fixtures are removed. + const fixtureEvidence = { + completedAt: new Date().toISOString(), + document: await fileEvidence(documentPath, context.document.savedMarkdown), + folder: { + addedDocument: await fileEvidence(addedFolderFilePath, `${context.folder.addedMarker}\n`), + initialDocument: await fileEvidence( + initialFolderFilePath, + `${context.folder.initialMarker}\n`, + ), + path: folderPath, + }, + missingDocument: await fileEvidence(missingDocumentPath), + temporaryRoot, + }; + + await Promise.all([ + rm(temporaryRoot, { force: true, recursive: true }), + rm(e2eStoreDirectory, { force: true, recursive: true }), + ]); + + const webdriverPortReleased = await waitForPortRelease(WEBDRIVER_PORT); + + await writeJson(path.join(artifactsRoot, "fixture-manifest.json"), { + ...fixtureEvidence, + webdriverPortReleased, + }); + + if (scenarioError) { + throw scenarioError; + } + + if (!webdriverPortReleased) { + throw new Error( + `The desktop E2E suite left a listener on port ${WEBDRIVER_PORT}. Stop it before the next run.`, + ); } }; diff --git a/e2e/desktop/support/suite.ts b/e2e/desktop/support/suite.ts new file mode 100644 index 0000000..01f3ca6 --- /dev/null +++ b/e2e/desktop/support/suite.ts @@ -0,0 +1 @@ +export const WEBDRIVER_PORT = 4445; diff --git a/e2e/desktop/wdio.conf.ts b/e2e/desktop/wdio.conf.ts index f5367e3..4516561 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,6 @@ const appBinaryPath = path.join( "debug", "leafdown-e2e.exe", ); -const webdriverPort = 4445; const requestedSpec = process.env.LEAFDOWN_E2E_SPEC; const closesApplicationUnderTest = process.env.LEAFDOWN_E2E_SCENARIO === "window-lifecycle"; let activeBrowser: WebdriverIO.Browser | undefined; @@ -42,7 +42,7 @@ export const config: WebdriverIO.Config = { { appBinaryPath, driverProvider: "embedded", - embeddedPort: webdriverPort, + embeddedPort: WEBDRIVER_PORT, captureBackendLogs: true, captureFrontendLogs: true, backendLogLevel: "info", From b14972f4e4c320ed2323aa4432cd98630fc24400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 9 Aug 2026 13:41:59 -0300 Subject: [PATCH 08/11] Simplify the desktop E2E suite --- CONTRIBUTING.md | 12 +- docs/architecture.md | 2 +- e2e/desktop/run.ts | 187 +++++-------------- e2e/desktop/specs/persistence-restart.e2e.ts | 30 +-- e2e/desktop/specs/persistence-write.e2e.ts | 16 +- e2e/desktop/specs/window-lifecycle.e2e.ts | 63 ++----- e2e/desktop/support/artifacts.ts | 9 +- e2e/desktop/support/diagnostics.ts | 8 +- e2e/desktop/support/runContext.ts | 1 - e2e/desktop/support/suite.ts | 4 + e2e/desktop/support/ui.ts | 60 ++---- e2e/desktop/wdio.conf.ts | 24 ++- 12 files changed, 116 insertions(+), 300 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bf3a16..1cb3636 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,11 +80,17 @@ Run the Windows-local assembled desktop E2E suite with: pnpm test:e2e:desktop ``` -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 suite retains the Diagnostics smoke test and narrowly covers document open/edit/save/reopen, folder navigation and native watcher refresh, missing-file error propagation, settings persistence across a process restart, injected frame controls, and the clean window-close handshake. 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. +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. -The runner seeds only the isolated E2E persisted store, creates temporary filesystem fixtures, and removes both after the suite. Each run writes shared fixture evidence under `e2e/desktop/artifacts//` and 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. These artifacts are retained until manually deleted. Treat them as potentially sensitive because diagnostics and errors may contain local paths. +The runner seeds only the isolated E2E persisted store, 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. 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 the shell and run the suite. The Diagnostics scenario should fail, retain its evidence, clean its fixture and store state, and return a nonzero exit code. +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). diff --git a/docs/architecture.md b/docs/architecture.md index 839ab81..e15926f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,6 +171,6 @@ Automated tests focus on: 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. The runner uses a dedicated application identifier, resets only that identifier's persisted store, creates temporary fixtures, and cleans both after the run. WebDriver plugins, permissions, and frontend integration remain limited to the dedicated desktop E2E build and are excluded from ordinary application builds. +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/run.ts b/e2e/desktop/run.ts index 2707864..4553218 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -1,6 +1,5 @@ import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -8,17 +7,17 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import type { DesktopE2ERunContext } from "./support/runContext.js"; -import { WEBDRIVER_PORT } from "./support/suite.js"; +import { RUN_LABEL, WEBDRIVER_PORT } from "./support/suite.js"; interface Scenario { name: string; - prepareState: (context: DesktopE2ERunContext) => Promise; - spec: string; + continues?: string; + recentFiles?: string[]; + recentFolders?: string[]; } const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); -const runLabel = `${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`; -const artifactsRoot = path.join(repositoryRoot, "e2e", "desktop", "artifacts", runLabel); +const artifactsRoot = path.join(repositoryRoot, "e2e", "desktop", "artifacts", RUN_LABEL); const contextPath = path.join(artifactsRoot, "run-context.json"); const e2eStoreDirectory = path.join( process.env.APPDATA ?? "", @@ -28,31 +27,13 @@ const e2eStoreDirectory = path.join( const recentItemsPath = path.join(e2eStoreDirectory, "recent-items.dev.json"); const settingsPath = path.join(e2eStoreDirectory, "settings.dev.json"); -const initialSettings = { - articleSortOrder: "name", - autoPairBracketsAndQuotes: true, - defaultNewDocumentExtension: ".md", - defaultNewDocumentLineEnding: "crlf", - ignoredDirectories: [".git", ".hg", ".svn", "node_modules", "target", "dist", "build", ".cache"], - indexFileNames: ["readme", "index"], - insertFinalNewline: true, - recordRecentItems: true, - sidebarVisible: true, - softWrapCodeBlocks: false, - theme: "system", - version: 1, -}; - 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 Promise.all([ - writeJson(recentItemsPath, { recentFiles, recentFolders, version: 1 }), - writeJson(settingsPath, initialSettings), - ]); + await writeJson(recentItemsPath, { recentFiles, recentFolders, version: 1 }); }; const runWdio = (scenario: Scenario) => @@ -70,10 +51,10 @@ const runWdio = (scenario: Scenario) => cwd: repositoryRoot, env: { ...process.env, - LEAFDOWN_E2E_ARTIFACT_RUN: runLabel, + LEAFDOWN_E2E_ARTIFACT_RUN: RUN_LABEL, LEAFDOWN_E2E_CONTEXT_PATH: contextPath, LEAFDOWN_E2E_SCENARIO: scenario.name, - LEAFDOWN_E2E_SPEC: scenario.spec, + LEAFDOWN_E2E_SPEC: `e2e/desktop/specs/${scenario.name}.e2e.ts`, }, stdio: "inherit", }); @@ -93,55 +74,28 @@ const runWdio = (scenario: Scenario) => }); }); -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 metadata = await stat(filePath); - - return { - ...expected, - modifiedAt: metadata.mtime.toISOString(), - path: filePath, - sha256: sha256(contents), - sizeBytes: metadata.size, - }; - } catch (error) { - return { ...expected, error: String(error), path: filePath }; - } -}; - -const isPortFree = (port: number) => +const isPortFree = () => new Promise((resolve) => { const server = createServer(); server.once("error", () => resolve(false)); server.once("listening", () => server.close(() => resolve(true))); - server.listen(port, "127.0.0.1"); + server.listen(WEBDRIVER_PORT, "127.0.0.1"); }); // The embedded driver releases the port asynchronously as it shuts down. -const waitForPortRelease = async (port: number, timeoutMs = 10_000) => { +const waitForPortRelease = async (timeoutMs = 10_000) => { const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await isPortFree(port)) { - return true; + while (!(await isPortFree())) { + if (Date.now() > deadline) { + return false; } await delay(250); } - return isPortFree(port); + return true; }; const main = async () => { @@ -177,7 +131,6 @@ const main = async () => { path: folderPath, }, missingDocumentPath, - persistenceEvidencePath: path.join(artifactsRoot, "persistence-phase-one.json"), settingsPath, temporaryRoot, }; @@ -194,90 +147,40 @@ const main = async () => { await writeJson(contextPath, context); const scenarios: Scenario[] = [ - { - name: "diagnostics", - prepareState: () => resetPersistedState(), - spec: "e2e/desktop/specs/diagnostics.e2e.ts", - }, - { - name: "document-lifecycle", - prepareState: ({ document }) => resetPersistedState([document.path]), - spec: "e2e/desktop/specs/document-lifecycle.e2e.ts", - }, - { - name: "folder-watcher", - prepareState: ({ folder }) => resetPersistedState([], [folder.path]), - spec: "e2e/desktop/specs/folder-watcher.e2e.ts", - }, - { - name: "missing-document-error", - prepareState: ({ missingDocumentPath }) => resetPersistedState([missingDocumentPath]), - spec: "e2e/desktop/specs/missing-document-error.e2e.ts", - }, - { - name: "persistence-write", - prepareState: () => resetPersistedState(), - spec: "e2e/desktop/specs/persistence-write.e2e.ts", - }, - { - name: "persistence-restart", - prepareState: () => Promise.resolve(), - spec: "e2e/desktop/specs/persistence-restart.e2e.ts", - }, - { - name: "window-lifecycle", - prepareState: () => resetPersistedState(), - spec: "e2e/desktop/specs/window-lifecycle.e2e.ts", - }, + { 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" }, ]; - let scenarioError: unknown; - try { - for (const scenario of scenarios) { - await scenario.prepareState(context); - await runWdio(scenario); - } - } catch (error) { - scenarioError = error; - } - - // Gather evidence before the fixtures are removed. - const fixtureEvidence = { - completedAt: new Date().toISOString(), - document: await fileEvidence(documentPath, context.document.savedMarkdown), - folder: { - addedDocument: await fileEvidence(addedFolderFilePath, `${context.folder.addedMarker}\n`), - initialDocument: await fileEvidence( - initialFolderFilePath, - `${context.folder.initialMarker}\n`, - ), - path: folderPath, - }, - missingDocument: await fileEvidence(missingDocumentPath), - temporaryRoot, - }; - - await Promise.all([ - rm(temporaryRoot, { force: true, recursive: true }), - rm(e2eStoreDirectory, { force: true, recursive: true }), - ]); - - const webdriverPortReleased = await waitForPortRelease(WEBDRIVER_PORT); - - await writeJson(path.join(artifactsRoot, "fixture-manifest.json"), { - ...fixtureEvidence, - webdriverPortReleased, - }); + 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); + } - if (scenarioError) { - throw scenarioError; - } + await runWdio(scenario); - if (!webdriverPortReleased) { - throw new Error( - `The desktop E2E suite left a listener on port ${WEBDRIVER_PORT}. Stop it before the next run.`, - ); + if (!(await waitForPortRelease())) { + throw new Error( + `Scenario ${scenario.name} left a listener on port ${WEBDRIVER_PORT}. Stop it before the next run.`, + ); + } + } + } finally { + await Promise.all([ + rm(temporaryRoot, { force: true, recursive: true }), + rm(e2eStoreDirectory, { force: true, recursive: true }), + ]); } }; diff --git a/e2e/desktop/specs/persistence-restart.e2e.ts b/e2e/desktop/specs/persistence-restart.e2e.ts index 5f7335b..ce2bb45 100644 --- a/e2e/desktop/specs/persistence-restart.e2e.ts +++ b/e2e/desktop/specs/persistence-restart.e2e.ts @@ -1,25 +1,12 @@ import { $, browser, expect } from "@wdio/globals"; -import { readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; +import { readFile } from "node:fs/promises"; -import { ARTIFACTS_DIR } from "../support/artifacts.js"; -import { getDiagnosticsSummary } from "../support/diagnostics.js"; import { getDesktopE2ERunContext } from "../support/runContext.js"; import { findMenuItem, openMenu } from "../support/ui.js"; -interface PersistencePhaseOneEvidence { - firstRunId: string; -} - describe("desktop persistence after restart", () => { it("restores the sidebar setting in a fresh packaged-app process", async () => { - const { persistenceEvidencePath, settingsPath } = await getDesktopE2ERunContext(); - const phaseOne = JSON.parse( - await readFile(persistenceEvidencePath, "utf8"), - ) as PersistencePhaseOneEvidence; - const diagnostics = await getDiagnosticsSummary(); - - expect(diagnostics.runId).not.toBe(phaseOne.firstRunId); + const { settingsPath } = await getDesktopE2ERunContext(); await openMenu("View"); const sidebarItem = await findMenuItem((text) => text.startsWith("Toggle sidebar")); @@ -32,18 +19,5 @@ describe("desktop persistence after restart", () => { unknown >; expect(persistedSettings.sidebarVisible).toBe(false); - - await writeFile( - path.join(ARTIFACTS_DIR, "restart-evidence.json"), - `${JSON.stringify( - { - firstRunId: phaseOne.firstRunId, - persistedSidebarVisible: persistedSettings.sidebarVisible, - secondRunId: diagnostics.runId, - }, - null, - 2, - )}\n`, - ); }); }); diff --git a/e2e/desktop/specs/persistence-write.e2e.ts b/e2e/desktop/specs/persistence-write.e2e.ts index 5ae1434..0b92cd9 100644 --- a/e2e/desktop/specs/persistence-write.e2e.ts +++ b/e2e/desktop/specs/persistence-write.e2e.ts @@ -1,14 +1,12 @@ import { $, browser, expect } from "@wdio/globals"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; -import { getDiagnosticsSummary } from "../support/diagnostics.js"; 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 { persistenceEvidencePath, settingsPath } = await getDesktopE2ERunContext(); - const diagnostics = await getDiagnosticsSummary(); + const { settingsPath } = await getDesktopE2ERunContext(); await openMenu("View"); const sidebarItem = await findMenuItem((text) => text.startsWith("Toggle sidebar")); @@ -17,25 +15,19 @@ describe("desktop persistence before restart", () => { await expect($("aria/Article navigator")).not.toExist(); - let persistedSettings: Record | undefined; await browser.waitUntil( async () => { try { - persistedSettings = JSON.parse(await readFile(settingsPath, "utf8")) as Record< + const persisted = JSON.parse(await readFile(settingsPath, "utf8")) as Record< string, unknown >; - return persistedSettings.sidebarVisible === false; + return persisted.sidebarVisible === false; } catch { return false; } }, { timeoutMsg: "The sidebar setting was not persisted before restart." }, ); - - await writeFile( - persistenceEvidencePath, - `${JSON.stringify({ firstRunId: diagnostics.runId, persistedSettings }, null, 2)}\n`, - ); }); }); diff --git a/e2e/desktop/specs/window-lifecycle.e2e.ts b/e2e/desktop/specs/window-lifecycle.e2e.ts index 722d2ab..a4cb8f4 100644 --- a/e2e/desktop/specs/window-lifecycle.e2e.ts +++ b/e2e/desktop/specs/window-lifecycle.e2e.ts @@ -1,9 +1,7 @@ import { $, $$, browser, expect } from "@wdio/globals"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; -import { ARTIFACTS_DIR } from "../support/artifacts.js"; -import { type DiagnosticRecord, getDiagnosticsSummary } from "../support/diagnostics.js"; +import { getDiagnosticsSummary, readRunDiagnostics } from "../support/diagnostics.js"; const WINDOW_CONTROL_LABELS = ["Minimize window", "Maximize window", "Close window"]; @@ -18,9 +16,7 @@ const waitForNodeCondition = async ( return; } - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + await delay(100); } throw new Error(timeoutMessage); @@ -35,30 +31,6 @@ const isProcessRunning = (processId: number) => { } }; -const readClosingDiagnostic = async (logFilePath: string, runId: string) => { - const contents = await readFile(logFilePath, "utf8"); - - for (const line of contents.split(/\r?\n/u)) { - try { - const record = JSON.parse(line) as DiagnosticRecord; - - if ( - record.runId === runId && - record.event === "operationLifecycle" && - record.feature === "app" && - record.operation === "window" && - record.phase === "closing" - ) { - return record; - } - } catch { - // The app log can contain an incomplete final line while it is being flushed. - } - } - - return undefined; -}; - describe("desktop window lifecycle", () => { it("exposes injected frame controls and exits through the real close handshake", async () => { const diagnostics = await getDiagnosticsSummary(); @@ -88,25 +60,16 @@ describe("desktop window lifecycle", () => { `The packaged application process ${processId} did not exit after Close window.`, ); - let closingDiagnostic: DiagnosticRecord | undefined; - await waitForNodeCondition(async () => { - closingDiagnostic = await readClosingDiagnostic(diagnostics.logFilePath, diagnostics.runId); - return Boolean(closingDiagnostic); - }, "The clean window-closing diagnostic was not flushed before process exit."); - - await mkdir(ARTIFACTS_DIR, { recursive: true }); - await writeFile( - path.join(ARTIFACTS_DIR, "window-close-evidence.json"), - `${JSON.stringify( - { - closingDiagnostic, - controls: WINDOW_CONTROL_LABELS, - processExited: true, - processId, - }, - null, - 2, - )}\n`, + 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 6f4b61c..5107173 100644 --- a/e2e/desktop/support/artifacts.ts +++ b/e2e/desktop/support/artifacts.ts @@ -3,6 +3,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { RUN_LABEL } from "./suite.js"; + interface DiagnosticsSummary { appIdentifier: string; appName: string; @@ -18,19 +20,16 @@ interface DiagnosticsSummary { } 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; +process.env.LEAFDOWN_E2E_ARTIFACT_RUN = RUN_LABEL; export const ARTIFACTS_DIR = path.join( repositoryRoot, "e2e", "desktop", "artifacts", - runLabel, + RUN_LABEL, ...(scenarioLabel ? [scenarioLabel] : []), ); diff --git a/e2e/desktop/support/diagnostics.ts b/e2e/desktop/support/diagnostics.ts index 2a6a3f1..b4c1a86 100644 --- a/e2e/desktop/support/diagnostics.ts +++ b/e2e/desktop/support/diagnostics.ts @@ -25,7 +25,7 @@ export const getDiagnosticsSummary = () => ({ core }) => core.invoke("get_diagnostics_summary") as Promise, ); -const readRunDiagnostics = async ({ logFilePath, runId }: DiagnosticsSummary) => { +export const readRunDiagnostics = async ({ logFilePath, runId }: DiagnosticsSummary) => { const contents = await readFile(logFilePath, "utf8"); return contents @@ -56,15 +56,11 @@ export const waitForDiagnosticRecord = async ( { timeoutMsg: "Expected structured application diagnostic did not appear." }, ); - if (!result.record) { - throw new Error("Expected structured application diagnostic did not appear."); - } - await mkdir(ARTIFACTS_DIR, { recursive: true }); await writeFile( path.join(ARTIFACTS_DIR, evidenceFileName), `${JSON.stringify(result.record, null, 2)}\n`, ); - return result.record; + return result.record!; }; diff --git a/e2e/desktop/support/runContext.ts b/e2e/desktop/support/runContext.ts index e1bf572..8921bb8 100644 --- a/e2e/desktop/support/runContext.ts +++ b/e2e/desktop/support/runContext.ts @@ -17,7 +17,6 @@ export interface DesktopE2ERunContext { path: string; }; missingDocumentPath: string; - persistenceEvidencePath: string; settingsPath: string; temporaryRoot: string; } diff --git a/e2e/desktop/support/suite.ts b/e2e/desktop/support/suite.ts index 01f3ca6..a9781a7 100644 --- a/e2e/desktop/support/suite.ts +++ b/e2e/desktop/support/suite.ts @@ -1 +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 index 580b80d..37e7ecb 100644 --- a/e2e/desktop/support/ui.ts +++ b/e2e/desktop/support/ui.ts @@ -1,21 +1,17 @@ import { $, $$, browser } from "@wdio/globals"; -type MenuItemPredicate = (text: string) => boolean; +const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'; -export const openMenu = async (label: string) => { - await $(`aria/${label}`).click(); -}; - -export const findMenuItem = async (predicate: MenuItemPredicate) => { +const findByText = async ( + selector: string, + predicate: (text: string) => boolean, + timeoutMsg: string, +) => { const result: { item?: WebdriverIO.Element } = {}; await browser.waitUntil( async () => { - const items = await $$( - '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]', - ).getElements(); - - for (const item of items) { + for (const item of await $$(selector).getElements()) { if (predicate((await item.getText()).trim())) { result.item = item; return true; @@ -24,16 +20,22 @@ export const findMenuItem = async (predicate: MenuItemPredicate) => { return false; }, - { timeoutMsg: "Expected menu item did not appear." }, + { timeoutMsg }, ); - if (!result.item) { - throw new Error("Expected menu item did not appear."); - } + return result.item!; +}; - 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"); @@ -50,29 +52,3 @@ export const selectFileMenuItem = async (label: string) => { await openMenu("File"); await (await findMenuItem((text) => text.startsWith(label))).click(); }; - -export const findTreeItem = async (label: string) => { - const result: { item?: WebdriverIO.Element } = {}; - - await browser.waitUntil( - async () => { - const items = await $$('[role="treeitem"]').getElements(); - - for (const item of items) { - if ((await item.getText()).trim() === label) { - result.item = item; - return true; - } - } - - return false; - }, - { timeoutMsg: `Tree item ${label} did not appear.` }, - ); - - if (!result.item) { - throw new Error(`Tree item ${label} did not appear.`); - } - - return result.item; -}; diff --git a/e2e/desktop/wdio.conf.ts b/e2e/desktop/wdio.conf.ts index 4516561..2c09f1e 100644 --- a/e2e/desktop/wdio.conf.ts +++ b/e2e/desktop/wdio.conf.ts @@ -15,7 +15,11 @@ const appBinaryPath = path.join( "leafdown-e2e.exe", ); const requestedSpec = process.env.LEAFDOWN_E2E_SPEC; -const closesApplicationUnderTest = process.env.LEAFDOWN_E2E_SCENARIO === "window-lifecycle"; + +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[] = [ @@ -29,11 +33,7 @@ const capabilities: TauriCapabilities[] = [ export const config: WebdriverIO.Config = { runner: "local", - specs: [ - requestedSpec - ? path.resolve(repositoryRoot, requestedSpec) - : path.join(repositoryRoot, "e2e", "desktop", "specs", "diagnostics.e2e.ts"), - ], + specs: [path.resolve(repositoryRoot, requestedSpec)], maxInstances: 1, capabilities, services: [ @@ -65,10 +65,14 @@ export const config: WebdriverIO.Config = { activeBrowser = browser; await browser.setWindowSize(1024, 768); }, - after: () => { - if (closesApplicationUnderTest && activeBrowser) { - // The terminal scenario already destroyed the embedded WebDriver with the app. - // Clear the local handle so WDIO does not issue a DELETE to a server that no longer exists. + after: async () => { + if (!activeBrowser) { + return; + } + + try { + await activeBrowser.getTitle(); + } catch { activeBrowser.sessionId = ""; } }, From 110664b04979eccbdc3f36a993ee999c1f0e5257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 9 Aug 2026 13:42:27 -0300 Subject: [PATCH 09/11] Stop tracking an environment example --- .env.example | 2 -- .gitignore | 1 - 2 files changed, 3 deletions(-) delete mode 100644 .env.example 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/ From 7dfbee63bbc756a53cd1a69d9a4cb482ad9ed00a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 9 Aug 2026 13:57:03 -0300 Subject: [PATCH 10/11] Share one diagnostics summary contract --- CONTRIBUTING.md | 2 +- e2e/desktop/specs/diagnostics.e2e.ts | 10 ++------ e2e/desktop/specs/folder-watcher.e2e.ts | 1 - .../specs/missing-document-error.e2e.ts | 1 - e2e/desktop/support/artifacts.ts | 17 +------------ e2e/desktop/support/diagnostics.ts | 25 ++++++++----------- 6 files changed, 15 insertions(+), 41 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1cb3636..3d4ab0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,7 @@ pnpm test:e2e:desktop 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. -The runner seeds only the isolated E2E persisted store, 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. 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. 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, run the suite with the forced-failure flag: diff --git a/e2e/desktop/specs/diagnostics.e2e.ts b/e2e/desktop/specs/diagnostics.e2e.ts index 01c6b5b..f848a1e 100644 --- a/e2e/desktop/specs/diagnostics.e2e.ts +++ b/e2e/desktop/specs/diagnostics.e2e.ts @@ -1,12 +1,8 @@ import { $, browser, expect } from "@wdio/globals"; +import { getDiagnosticsSummary } from "../support/diagnostics.js"; import { openMenu } from "../support/ui.js"; -interface DiagnosticsSummary { - appIdentifier: string; - runId: string; -} - describe("desktop diagnostics", () => { it("opens Diagnostics through Help and corroborates the summary through real IPC", async () => { await openMenu("Help"); @@ -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/folder-watcher.e2e.ts b/e2e/desktop/specs/folder-watcher.e2e.ts index b4988cd..6740e29 100644 --- a/e2e/desktop/specs/folder-watcher.e2e.ts +++ b/e2e/desktop/specs/folder-watcher.e2e.ts @@ -24,7 +24,6 @@ describe("desktop folder context watcher", () => { record.feature === "folder-context" && record.operation === "folderContextWatcher" && record.phase === "started", - "watcher-started.json", ); await writeFile(folder.addedFilePath, `${folder.addedMarker}\n`); diff --git a/e2e/desktop/specs/missing-document-error.e2e.ts b/e2e/desktop/specs/missing-document-error.e2e.ts index 22a4029..0cc876f 100644 --- a/e2e/desktop/specs/missing-document-error.e2e.ts +++ b/e2e/desktop/specs/missing-document-error.e2e.ts @@ -21,7 +21,6 @@ describe("desktop backend error propagation", () => { record.operation === "openMarkdownDocument" && record.errorKind === "missingFile" && record.path === missingDocumentPath, - "missing-document-diagnostic.json", ); }); }); diff --git a/e2e/desktop/support/artifacts.ts b/e2e/desktop/support/artifacts.ts index 5107173..5b9814c 100644 --- a/e2e/desktop/support/artifacts.ts +++ b/e2e/desktop/support/artifacts.ts @@ -3,27 +3,12 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import type { DiagnosticsSummary } from "./diagnostics.js"; import { RUN_LABEL } from "./suite.js"; -interface DiagnosticsSummary { - appIdentifier: string; - appName: string; - appVersion: string; - architecture: string; - logDirectoryPath: string; - logFileCount: number; - logFileName: string; - logFilePath: string; - logMaxFileSizeBytes: number; - operatingSystem: string; - runId: string; -} - const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url)); const scenarioLabel = process.env.LEAFDOWN_E2E_SCENARIO; -process.env.LEAFDOWN_E2E_ARTIFACT_RUN = RUN_LABEL; - export const ARTIFACTS_DIR = path.join( repositoryRoot, "e2e", diff --git a/e2e/desktop/support/diagnostics.ts b/e2e/desktop/support/diagnostics.ts index b4c1a86..09be04d 100644 --- a/e2e/desktop/support/diagnostics.ts +++ b/e2e/desktop/support/diagnostics.ts @@ -1,11 +1,17 @@ import { browser } from "@wdio/globals"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import { ARTIFACTS_DIR } from "./artifacts.js"; +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; } @@ -40,10 +46,7 @@ export const readRunDiagnostics = async ({ logFilePath, runId }: DiagnosticsSumm .filter((record) => record.runId === runId); }; -export const waitForDiagnosticRecord = async ( - predicate: (record: DiagnosticRecord) => boolean, - evidenceFileName: string, -) => { +export const waitForDiagnosticRecord = async (predicate: (record: DiagnosticRecord) => boolean) => { const summary = await getDiagnosticsSummary(); const result: { record?: DiagnosticRecord } = {}; @@ -56,11 +59,5 @@ export const waitForDiagnosticRecord = async ( { timeoutMsg: "Expected structured application diagnostic did not appear." }, ); - await mkdir(ARTIFACTS_DIR, { recursive: true }); - await writeFile( - path.join(ARTIFACTS_DIR, evidenceFileName), - `${JSON.stringify(result.record, null, 2)}\n`, - ); - return result.record!; }; From e349f4e38f6b146fe469dc813b0152ad2b9aff87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 9 Aug 2026 14:20:26 -0300 Subject: [PATCH 11/11] Retain fixture evidence for failing runs The manifest records each temporary fixture's expected and actual hash, size, and modification time. Nothing else preserves that state: the failure artifacts capture the window, not the filesystem, and cleanup removes the temporary root moments later. Teardown drops the whole E2E identifier directory so window state does not outlive a run either. --- CONTRIBUTING.md | 2 +- e2e/desktop/run.ts | 56 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d4ab0c..59742d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,7 @@ pnpm test:e2e:desktop 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. -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. 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, run the suite with the forced-failure flag: diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 4553218..5b4b96e 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; -import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +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"; @@ -19,11 +20,8 @@ interface Scenario { 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 e2eStoreDirectory = path.join( - process.env.APPDATA ?? "", - "com.azganoth.leafdown.e2e", - "tauri-plugin-zustand", -); +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"); @@ -74,6 +72,33 @@ const runWdio = (scenario: Scenario) => }); }); +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(); @@ -176,10 +201,27 @@ const main = async () => { ); } } + } 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(e2eStoreDirectory, { force: true, recursive: true }), + rm(e2eAppDataDirectory, { force: true, recursive: true }), ]); } };