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: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Set to 1 only when verifying desktop E2E failure artifacts.
LEAFDOWN_E2E_FORCE_FAILURE=0
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
# dependencies
node_modules/

# local environment
.env
.env.*
!.env.example

# build output
dist/
target/
*.local
*.tsbuildinfo

coverage/
/e2e/desktop/artifacts/

# logs
logs
Expand Down
31 changes: 22 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ Verify a fresh development environment with:
pnpm check
```

Run the Windows-local assembled desktop smoke test 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.

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.

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.

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

For documentation-only or repository-metadata changes, run targeted formatting or validation instead of the full application suite unless executable configuration is affected. For example:
Expand Down Expand Up @@ -202,14 +214,15 @@ The [Leafdown Project](https://github.com/users/Azganoth/projects/7) contains th

## Command Reference

| Task | Command |
| --------------------------- | --------------------- |
| Run the desktop application | `pnpm tauri dev` |
| Run the web frontend only | `pnpm dev` |
| Check frontend changes | `pnpm check:frontend` |
| Check backend changes | `pnpm check:backend` |
| Check the whole repository | `pnpm check` |
| Format the repository | `pnpm format` |
| Build the desktop app | `pnpm tauri build` |
| Task | Command |
| --------------------------- | ----------------------- |
| Run the desktop application | `pnpm tauri dev` |
| Run the web frontend only | `pnpm dev` |
| Run the desktop smoke test | `pnpm test:e2e:desktop` |
| Check frontend changes | `pnpm check:frontend` |
| Check backend changes | `pnpm check:backend` |
| Check the whole repository | `pnpm check` |
| Format the repository | `pnpm format` |
| Build the desktop app | `pnpm tauri build` |

Treat [`package.json`](./package.json) as the source of truth for individual lint, test, formatting, and build scripts. Backend checks and formatting use the pinned Rust toolchain.
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,6 @@ 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 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.
39 changes: 39 additions & 0 deletions e2e/desktop/specs/diagnostics.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { $, browser, expect } from "@wdio/globals";

interface DiagnosticsSummary {
appIdentifier: string;
runId: string;
}

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 $("aria/Diagnostics...").click();

const dialog = $("aria/Diagnostics");
await expect(dialog).toBeDisplayed();

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 summaryText = await summaryField.getValue();

expect(summary.appIdentifier).toBe("com.azganoth.leafdown.e2e");
expect(summary.runId).not.toHaveLength(0);
expect(summaryText).toContain(`Identifier: ${summary.appIdentifier}`);
expect(summaryText).toContain(`Run: ${summary.runId}`);

await browser.execute((runId) => {
console.info(JSON.stringify({ event: "desktopE2eFrontendMarker", runId }));
}, summary.runId);

if (process.env.LEAFDOWN_E2E_FORCE_FAILURE === "1") {
throw new Error("Forced desktop E2E failure for artifact verification.");
}
});
});
109 changes: 109 additions & 0 deletions e2e/desktop/support/artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { browser } from "@wdio/globals";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

interface DiagnosticsSummary {
appIdentifier: string;
appName: string;
appVersion: string;
architecture: string;
logDirectoryPath: string;
logFileCount: number;
logFileName: string;
logFilePath: string;
logMaxFileSizeBytes: number;
operatingSystem: string;
runId: string;
}

const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url));
const runLabel =
process.env.LEAFDOWN_E2E_ARTIFACT_RUN ??
`${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`;

process.env.LEAFDOWN_E2E_ARTIFACT_RUN = runLabel;

export const ARTIFACTS_DIR = path.join(repositoryRoot, "e2e", "desktop", "artifacts", runLabel);

const writeJson = async (fileName: string, value: unknown) => {
await writeFile(path.join(ARTIFACTS_DIR, fileName), `${JSON.stringify(value, null, 2)}\n`);
};

const describeUnknownError = (error: unknown) => {
if (typeof error === "string") {
return error;
}

try {
return JSON.stringify(error) ?? "Unknown non-Error value";
} catch {
return "Unserializable non-Error value";
}
};

export const captureFailureArtifacts = async (testError?: unknown) => {
await mkdir(ARTIFACTS_DIR, { recursive: true });

const captureErrors: Record<string, string> = {};

try {
await browser.saveScreenshot(path.join(ARTIFACTS_DIR, "failure.png"));
} catch (error) {
captureErrors.screenshot = String(error);
}

try {
const diagnostics = await browser.tauri.execute(
({ core }) => core.invoke("get_diagnostics_summary") as Promise<DiagnosticsSummary>,
);
await writeJson("diagnostics.json", diagnostics);
} catch (error) {
captureErrors.diagnostics = String(error);
}

try {
const semanticState = await browser.execute(() =>
Array.from(
document.querySelectorAll<HTMLElement>(
"[role], button, a, input, textarea, select, [aria-label], [aria-labelledby]",
),
)
.filter((element) => !element.closest("[contenteditable='true']"))
.map((element) => {
const containsEditorContent = Boolean(element.querySelector("[contenteditable='true']"));

return {
ariaChecked: element.getAttribute("aria-checked"),
ariaDisabled: element.getAttribute("aria-disabled"),
ariaExpanded: element.getAttribute("aria-expanded"),
ariaLabel: element.getAttribute("aria-label"),
ariaLabelledBy: element.getAttribute("aria-labelledby"),
disabled: "disabled" in element ? Boolean(element.disabled) : undefined,
role: element.getAttribute("role"),
tag: element.tagName.toLowerCase(),
text: containsEditorContent
? undefined
: element.innerText?.trim().slice(0, 200) || undefined,
};
}),
);
await writeJson("semantic-state.json", semanticState);
} catch (error) {
captureErrors.semanticState = String(error);
}

await writeJson("failure.json", {
captureErrors,
error:
testError instanceof Error
? {
message: testError.message,
name: testError.name,
stack: testError.stack,
}
: testError === undefined
? undefined
: { message: describeUnknownError(testError) },
});
};
30 changes: 30 additions & 0 deletions e2e/desktop/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Leafdown E2E",
"mainBinaryName": "leafdown-e2e",
"identifier": "com.azganoth.leafdown.e2e",
"build": {
"beforeBuildCommand": "pnpm build:frontend:e2e"
},
"app": {
"security": {
"capabilities": [
"default",
{
"identifier": "desktop-e2e",
"description": "Test-only WebdriverIO access for the Windows desktop smoke harness",
"windows": ["main"],
"permissions": [
"wdio:allow-execute",
"wdio:allow-get-window-states",
"wdio:allow-log-frontend",
"wdio-webdriver:default"
]
}
]
}
},
"bundle": {
"active": false
}
}
15 changes: 15 additions & 0 deletions e2e/desktop/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"lib": [
"ES2024",
"ESNext.Array",
"ESNext.Collection",
"ESNext.Iterator",
"ESNext.Promise",
"DOM"
],
"types": ["node", "@wdio/globals/types", "@wdio/mocha-framework", "@wdio/tauri-service"]
},
"include": ["./**/*.ts"]
}
69 changes: 69 additions & 0 deletions e2e/desktop/wdio.conf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { TauriCapabilities } from "@wdio/tauri-service";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { ARTIFACTS_DIR, captureFailureArtifacts } from "./support/artifacts.js";

const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url));
const appBinaryPath = path.join(
repositoryRoot,
"src-tauri",
"target",
"desktop-e2e",
"debug",
"leafdown-e2e.exe",
);
const webdriverPort = 4445;

const capabilities: TauriCapabilities[] = [
{
browserName: "tauri",
"tauri:options": {
application: appBinaryPath,
},
},
];

export const config: WebdriverIO.Config = {
runner: "local",
specs: [path.join(repositoryRoot, "e2e", "desktop", "specs", "diagnostics.e2e.ts")],
maxInstances: 1,
capabilities,
services: [
[
"@wdio/tauri-service",
{
appBinaryPath,
driverProvider: "embedded",
embeddedPort: webdriverPort,
captureBackendLogs: true,
captureFrontendLogs: true,
backendLogLevel: "info",
frontendLogLevel: "info",
logDir: ARTIFACTS_DIR,
},
],
],
outputDir: ARTIFACTS_DIR,
logLevel: "info",
framework: "mocha",
reporters: ["spec"],
waitforTimeout: 10_000,
connectionRetryTimeout: 90_000,
connectionRetryCount: 0,
mochaOpts: {
timeout: 60_000,
},
before: async (_capabilities, _specs, browser: WebdriverIO.Browser) => {
await browser.setWindowSize(1024, 768);
},
afterTest: async (_test, _context, { error, passed }) => {
if (!passed) {
try {
await captureFailureArtifacts(error);
} catch (captureError) {
console.error("Desktop E2E failure artifact capture failed.", captureError);
}
}
},
};
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"dev": "vite",
"build": "pnpm build:frontend",
"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",
"preview": "vite preview",
"tauri": "tauri",
"lint": "pnpm lint:frontend && pnpm lint:backend",
Expand Down Expand Up @@ -69,17 +72,27 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"@vitest/coverage-v8": "^4.1.10",
"@wdio/cli": "9.30.1",
"@wdio/globals": "9.29.1",
"@wdio/local-runner": "9.30.1",
"@wdio/mocha-framework": "9.30.1",
"@wdio/spec-reporter": "9.29.1",
"@wdio/tauri-plugin": "1.3.0",
"@wdio/tauri-service": "1.3.0",
"@wdio/types": "9.30.1",
"babel-plugin-react-compiler": "^1.0.0",
"happy-dom": "^20.11.1",
"husky": "^9.1.7",
"lint-staged": "^17.2.0",
"oxfmt": "^0.61.0",
"oxlint": "^1.76.0",
"oxlint-tsgolint": "^7.0.2001",
"tsx": "4.23.5",
"tw-animate-css": "^1.4.0",
"typescript": "~7.0.2",
"vite": "^8.1.5",
"vitest": "^4.1.10"
"vitest": "^4.1.10",
"webdriverio": "9.30.1"
},
"engines": {
"node": ">=24.0.0"
Expand Down
Loading