Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .env.example

This file was deleted.

1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ node_modules/
# local environment
.env
.env.*
!.env.example

# build output
dist/
Expand Down
16 changes: 11 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,23 @@ Verify a fresh development environment with:
pnpm check
```

Run the Windows-local assembled desktop smoke test with:
Run the Windows-local assembled desktop E2E suite with:

```powershell
pnpm test:e2e:desktop
```

This explicit smoke test is not part of `pnpm check`. It builds an isolated debug binary with test-only WebDriver capabilities, starts one embedded WebDriver worker on port 4445, and exercises the real Tauri application and IPC boundary. The embedded provider does not require an external WebDriver. Keep port 4445 available while it runs. The test identifier and target directory are separate from ordinary Leafdown builds.
This explicit suite is not part of `pnpm check`. It builds an isolated debug binary with test-only WebDriver capabilities, then runs one embedded WebDriver worker at a time on port 4445 across fresh application sessions. The embedded provider does not require an external WebDriver. Keep port 4445 available while it runs. The test identifier, persisted store, and target directory are separate from ordinary Leafdown builds.

Each run writes ignored runner, frontend, and backend logs under `e2e/desktop/artifacts/<run>/`. 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/<run>/<scenario>/`. 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/<run>/`, recording each temporary fixture's path, expected and actual hash and size, and modification time before cleanup removes it. These artifacts are retained until manually deleted. Treat them as potentially sensitive because diagnostics and errors may contain local paths.

To verify the failure-evidence path, temporarily set `LEAFDOWN_E2E_FORCE_FAILURE=1` in `.env`, and run the smoke test. The test should fail and retain its evidence.
To verify the failure-evidence path, run the suite with the forced-failure flag:

```powershell
$env:LEAFDOWN_E2E_FORCE_FAILURE=1; pnpm test:e2e:desktop; $env:LEAFDOWN_E2E_FORCE_FAILURE=$null
```

The Diagnostics scenario should fail, retain its evidence, clean its fixture and store state, and return a nonzero exit code.

Before substantial implementation, read the relevant sections of [`docs/architecture.md`](./docs/architecture.md) and [`docs/patterns.md`](./docs/patterns.md).

Expand Down Expand Up @@ -218,7 +224,7 @@ The [Leafdown Project](https://github.com/users/Azganoth/projects/7) contains th
| --------------------------- | ----------------------- |
| Run the desktop application | `pnpm tauri dev` |
| Run the web frontend only | `pnpm dev` |
| Run the desktop smoke test | `pnpm test:e2e:desktop` |
| Run the desktop E2E suite | `pnpm test:e2e:desktop` |
| Check frontend changes | `pnpm check:frontend` |
| Check backend changes | `pnpm check:backend` |
| Check the whole repository | `pnpm check` |
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ Automated tests focus on:
- Literal HTML rendering and script-execution prevention.
- Context popup layout and caret-based marker visibility.

The Windows-local assembled desktop smoke test complements those component and boundary tests. It runs one worker against an isolated debug binary, uses semantic UI interactions to open Help → Diagnostics, and verifies the visible summary against the real Tauri diagnostics command. Direct bridge execution may corroborate setup or diagnostic state, but it is not a substitute for the user-visible acceptance path. WebDriver plugins, permissions, and frontend integration remain limited to the dedicated desktop E2E build and are excluded from ordinary application builds.
The Windows-local assembled desktop E2E suite complements those component and boundary tests without replacing them. It runs one embedded WebDriver worker at a time against an isolated debug binary and starts fresh application processes for independent scenarios. The suite retains the Help → Diagnostics smoke path, then adds narrow assembled-boundary assertions for the document lifecycle, real folder-watcher refresh, typed backend error propagation, persisted settings across restart, injected frame controls, and the clean window-close handshake.

User-visible acceptance paths use semantic UI interactions. Direct bridge execution is limited to corroborating diagnostic state, while Node-side filesystem, persisted-store, log, and process access provides deterministic setup or evidence around the native boundary. WebDriver plugins, permissions, and frontend integration remain limited to the dedicated desktop E2E build and are excluded from ordinary application builds.

The manual [Markdown corpus](../corpus/README.md) complements automated tests for parsing, rendering, editing, serialization, folder navigation, and local resources. Keep corpus scenarios aligned with the specification when supported behavior changes; use its README for fixture taxonomy and byte-sensitive handling.
1 change: 1 addition & 0 deletions e2e/desktop/fixtures/document-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Initial fixture marker.
1 change: 1 addition & 0 deletions e2e/desktop/fixtures/folder-context/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Folder index fixture marker.
229 changes: 229 additions & 0 deletions e2e/desktop/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";

import type { DesktopE2ERunContext } from "./support/runContext.js";
import { RUN_LABEL, WEBDRIVER_PORT } from "./support/suite.js";

interface Scenario {
name: string;
continues?: string;
recentFiles?: string[];
recentFolders?: string[];
}

const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url));
const artifactsRoot = path.join(repositoryRoot, "e2e", "desktop", "artifacts", RUN_LABEL);
const contextPath = path.join(artifactsRoot, "run-context.json");
const e2eAppDataDirectory = path.join(process.env.APPDATA ?? "", "com.azganoth.leafdown.e2e");
const e2eStoreDirectory = path.join(e2eAppDataDirectory, "tauri-plugin-zustand");
const recentItemsPath = path.join(e2eStoreDirectory, "recent-items.dev.json");
const settingsPath = path.join(e2eStoreDirectory, "settings.dev.json");

const writeJson = (filePath: string, value: unknown) =>
writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);

const resetPersistedState = async (recentFiles: string[] = [], recentFolders: string[] = []) => {
await rm(e2eStoreDirectory, { force: true, recursive: true });
await mkdir(e2eStoreDirectory, { recursive: true });
await writeJson(recentItemsPath, { recentFiles, recentFolders, version: 1 });
};

const runWdio = (scenario: Scenario) =>
new Promise<void>((resolve, reject) => {
const wdioExecutable = path.join(
repositoryRoot,
"node_modules",
"@wdio",
"cli",
"bin",
"wdio.js",
);

const child = spawn(process.execPath, [wdioExecutable, "run", "e2e/desktop/wdio.conf.ts"], {
cwd: repositoryRoot,
env: {
...process.env,
LEAFDOWN_E2E_ARTIFACT_RUN: RUN_LABEL,
LEAFDOWN_E2E_CONTEXT_PATH: contextPath,
LEAFDOWN_E2E_SCENARIO: scenario.name,
LEAFDOWN_E2E_SPEC: `e2e/desktop/specs/${scenario.name}.e2e.ts`,
},
stdio: "inherit",
});

child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolve();
return;
}

reject(
new Error(
`Desktop E2E scenario ${scenario.name} failed (code=${String(code)}, signal=${String(signal)}).`,
),
);
});
});

const sha256 = (contents: Buffer | string) => createHash("sha256").update(contents).digest("hex");

const fileEvidence = async (filePath: string, expectedContents?: string) => {
const expected =
expectedContents === undefined
? {}
: {
expectedSha256: sha256(expectedContents),
expectedSizeBytes: Buffer.byteLength(expectedContents),
};

try {
const contents = await readFile(filePath);
const { mtime, size } = await stat(filePath);

return {
...expected,
modifiedAt: mtime.toISOString(),
path: filePath,
sha256: sha256(contents),
sizeBytes: size,
};
} catch (error) {
return { ...expected, error: String(error), path: filePath };
}
};

const isPortFree = () =>
new Promise<boolean>((resolve) => {
const server = createServer();

server.once("error", () => resolve(false));
server.once("listening", () => server.close(() => resolve(true)));
server.listen(WEBDRIVER_PORT, "127.0.0.1");
});

// The embedded driver releases the port asynchronously as it shuts down.
const waitForPortRelease = async (timeoutMs = 10_000) => {
const deadline = Date.now() + timeoutMs;

while (!(await isPortFree())) {
if (Date.now() > deadline) {
return false;
}

await delay(250);
}

return true;
};

const main = async () => {
if (!process.env.APPDATA) {
throw new Error("APPDATA is required for the Windows-local desktop E2E suite.");
}

await mkdir(artifactsRoot, { recursive: true });

const temporaryRoot = await mkdtemp(path.join(tmpdir(), "leafdown-desktop-e2e-"));
const documentPath = path.join(temporaryRoot, "document-lifecycle.md");
const folderPath = path.join(temporaryRoot, "folder-context");
const initialFolderFileName = "readme.md";
const initialFolderFilePath = path.join(folderPath, initialFolderFileName);
const addedFolderFileName = "watcher-added.md";
const addedFolderFilePath = path.join(folderPath, addedFolderFileName);
const missingDocumentPath = path.join(temporaryRoot, "missing-document.md");
const savedMarker = "Saved fixture marker.";
const context: DesktopE2ERunContext = {
document: {
initialMarker: "Initial fixture marker.",
path: documentPath,
savedMarkdown: `${savedMarker}\n`,
savedMarker,
},
folder: {
addedFileName: addedFolderFileName,
addedFilePath: addedFolderFilePath,
addedMarker: "Watcher-added fixture marker.",
initialFileName: initialFolderFileName,
initialFilePath: initialFolderFilePath,
initialMarker: "Folder index fixture marker.",
path: folderPath,
},
missingDocumentPath,
settingsPath,
temporaryRoot,
};

await mkdir(folderPath, { recursive: true });
await copyFile(
path.join(repositoryRoot, "e2e", "desktop", "fixtures", "document-lifecycle.md"),
documentPath,
);
await copyFile(
path.join(repositoryRoot, "e2e", "desktop", "fixtures", "folder-context", "readme.md"),
initialFolderFilePath,
);
await writeJson(contextPath, context);

const scenarios: Scenario[] = [
{ name: "diagnostics" },
{ name: "document-lifecycle", recentFiles: [documentPath] },
{ name: "folder-watcher", recentFolders: [folderPath] },
{ name: "missing-document-error", recentFiles: [missingDocumentPath] },
{ name: "persistence-write" },
{ name: "persistence-restart", continues: "persistence-write" },
{ name: "window-lifecycle" },
];

try {
for (const [index, scenario] of scenarios.entries()) {
if (scenario.continues) {
if (scenario.continues !== scenarios[index - 1]?.name) {
throw new Error(
`Scenario ${scenario.name} must run directly after ${scenario.continues}.`,
);
}
} else {
await resetPersistedState(scenario.recentFiles, scenario.recentFolders);
}

await runWdio(scenario);

if (!(await waitForPortRelease())) {
throw new Error(
`Scenario ${scenario.name} left a listener on port ${WEBDRIVER_PORT}. Stop it before the next run.`,
);
}
}
} catch (error) {
await writeJson(path.join(artifactsRoot, "fixture-manifest.json"), {
document: await fileEvidence(documentPath, context.document.savedMarkdown),
failedAt: new Date().toISOString(),
folderAddedDocument: await fileEvidence(
addedFolderFilePath,
`${context.folder.addedMarker}\n`,
),
folderInitialDocument: await fileEvidence(
initialFolderFilePath,
`${context.folder.initialMarker}\n`,
),
missingDocument: await fileEvidence(missingDocumentPath),
temporaryRoot,
});

throw error;
} finally {
await Promise.all([
rm(temporaryRoot, { force: true, recursive: true }),
rm(e2eAppDataDirectory, { force: true, recursive: true }),
]);
}
};

await main();
14 changes: 4 additions & 10 deletions e2e/desktop/specs/diagnostics.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { $, browser, expect } from "@wdio/globals";

interface DiagnosticsSummary {
appIdentifier: string;
runId: string;
}
import { getDiagnosticsSummary } from "../support/diagnostics.js";
import { openMenu } from "../support/ui.js";

describe("desktop diagnostics", () => {
it("opens Diagnostics through Help and corroborates the summary through real IPC", async () => {
const helpMenu = $("aria/Help");
await helpMenu.click();
await browser.keys("Enter");
await openMenu("Help");
await $("aria/Diagnostics...").click();

const dialog = $("aria/Diagnostics");
Expand All @@ -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<DiagnosticsSummary>,
);
const summary = await getDiagnosticsSummary();
const summaryText = await summaryField.getValue();

expect(summary.appIdentifier).toBe("com.azganoth.leafdown.e2e");
Expand Down
56 changes: 56 additions & 0 deletions e2e/desktop/specs/document-lifecycle.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { $, browser, expect } from "@wdio/globals";
import { readFile } from "node:fs/promises";
import { Key } from "webdriverio";

import { getDesktopE2ERunContext } from "../support/runContext.js";
import { getSaveMenuItem, openRecentPath, selectFileMenuItem } from "../support/ui.js";

describe("desktop document lifecycle", () => {
it("opens, edits, saves, and reopens a fixture through real IPC", async () => {
const { document } = await getDesktopE2ERunContext();

await openRecentPath(document.path);

const editor = $('[contenteditable="true"]');
await expect(editor).toBeDisplayed();
await expect(editor).toHaveText(expect.stringContaining(document.initialMarker));

const cleanSaveItem = await getSaveMenuItem();
await expect(cleanSaveItem).toHaveAttribute("data-disabled");
await browser.keys("Escape");

await editor.click();
await browser.keys([Key.Ctrl, "a"]);
await browser.keys(Key.NULL);
await editor.addValue(document.savedMarker);

const dirtySaveItem = await getSaveMenuItem();
await expect(dirtySaveItem).not.toHaveAttribute("data-disabled");
await browser.keys("Escape");

await editor.click();
await browser.keys([Key.Ctrl, "s", Key.NULL]);
await expect($('[data-slot="toast"][data-type="success"]')).toHaveText(
expect.stringContaining("Document saved."),
);

await browser.waitUntil(
async () => (await readFile(document.path, "utf8")) === document.savedMarkdown,
{
timeoutMsg: "The saved fixture did not reach the expected on-disk contents.",
},
);

const savedSaveItem = await getSaveMenuItem();
await expect(savedSaveItem).toHaveAttribute("data-disabled");
await browser.keys("Escape");

await selectFileMenuItem("Close document");
await expect($('[contenteditable="true"]')).not.toExist();

await openRecentPath(document.path);
await expect($('[contenteditable="true"]')).toHaveText(
expect.stringContaining(document.savedMarker),
);
});
});
Loading