diff --git a/.github/scripts/discord-thread-validator.mjs b/.github/scripts/discord-thread-validator.mjs index 88ae9c7ea6..2ab906833e 100644 --- a/.github/scripts/discord-thread-validator.mjs +++ b/.github/scripts/discord-thread-validator.mjs @@ -8,14 +8,13 @@ export async function validateThreadChannel(threadId, prNumber, { botToken, foru return false; } const VALIDATION_TIMEOUT_MS = 5_000; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS); try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS); const res = await fetch(`https://discord.com/api/v10/channels/${threadId}`, { headers: { Authorization: `Bot ${botToken}` }, signal: controller.signal, }); - clearTimeout(timeout); if (!res.ok) { warning(`Thread validation failed: channel ${threadId} returned ${res.status}`); return false; @@ -38,5 +37,12 @@ export async function validateThreadChannel(threadId, prNumber, { botToken, foru } catch (err) { warning(`Thread validation threw: ${err && err.message ? err.message : err}`); return false; + } finally { + // `finally`, not a line after the await: when `fetch` rejects — a real network + // error — the timer would otherwise stay armed and fire `controller.abort()` + // five seconds later, long after this returned. Under Vitest that lands after + // the worker has torn down, which is an intermittent post-run error rather + // than a failing test. Same shape as `callDiscord` in discord-bot-api.mjs. + clearTimeout(timeout); } } diff --git a/.github/scripts/discord-thread-validator.test.mjs b/.github/scripts/discord-thread-validator.test.mjs index f2c686b852..7e2718d1e5 100644 --- a/.github/scripts/discord-thread-validator.test.mjs +++ b/.github/scripts/discord-thread-validator.test.mjs @@ -108,4 +108,22 @@ describe("validateThreadChannel", () => { const result = await validateThreadChannel("500", number, { botToken }); expect(result).toBe(false); }); + + it("leaves no timer armed once it has returned, on either path", async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetch).mockRejectedValue(new Error("network error")); + await validateThreadChannel("500", number, { botToken }); + // The failing path is the one that used to leak: `clearTimeout` sat after + // the `await`, so a rejected fetch skipped it and left the 5s abort timer + // running past the end of the test. + expect(vi.getTimerCount()).toBe(0); + + vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404 }); + await validateThreadChannel("404", number, { botToken }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index f6ca4fbe64..99fc1ff0d9 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -21,20 +21,22 @@ Conventions for the Mavis reins when working in this repo. ## CI (`.github/workflows/ci.yml`) -CI runs on every PR to `main` and every push to `main`: +CI runs on every PR to `main`, `feat/ai-edition` and `release/**`, and on every push to those: - `npm run lint` (Biome) -- `npx tsc --noEmit` (TypeScript) +- `npx tsc --noEmit` (TypeScript, app code) +- `npx tsc -p tsconfig.test.json --noEmit` (TypeScript, test files — a separate gate at zero) - `npm run test` (Vitest unit) -- `npm run test:browser` (Vitest + Playwright headless) +- `npm run docs:check` - `npx vite build` (renderer build smoke) +- `cargo test` / `cargo check` for the compositor on macOS, Windows and Linux -All five must be green before merge. Native helper code is NOT covered by CI — manual smoke test is required for `electron/*-helper/` changes; note it in the PR description. +All must be green before merge. Native helper code is NOT covered by CI — manual smoke test is required for `electron/*-helper/` changes; note it in the PR description. ## Pull request flow 1. Branch from `main`. 2. Implement + add tests in the same package. -3. Run locally: `npm run lint && npx tsc --noEmit && npm run test`. For browser/e2e-touching changes, also run the relevant suite. +3. While implementing, run only the affected tests (`npx vitest --run ` or `--changed`); `npx tsc --noEmit` and `npm run lint` are the cheap inner-loop checks. Run the full `npm run test` **once**, here, before pushing. 4. Push and open the PR via `gh pr create`. Use `.github/pull_request_template.md`. 5. Wait for the Mavis reviewer (`openscreen-reviewer`) PASS or address the requested changes. 6. Merge once CI is green and review is PASS. PR titles must follow Conventional Commits (enforced by the `semantic-pr` job in `ci.yml`) — this keeps the auto-generated release notes clean. diff --git a/.harness/reins/openscreen-dev/agent.md b/.harness/reins/openscreen-dev/agent.md index 4bd9ce150e..a44c491a9d 100644 --- a/.harness/reins/openscreen-dev/agent.md +++ b/.harness/reins/openscreen-dev/agent.md @@ -26,6 +26,8 @@ You are the generalist implementer for the OpenScreen project — a free, open-s - `npx tsc --noEmit` passes. - `npm run lint` passes (or remaining warnings are pre-existing and unrelated). -- `npm run test` passes for any unit tests you added or affected. +- The tests you added or affected pass — run those files, `npx vitest --run `, not the + whole suite. `npm run test` is minutes; run it once at the end if at all, and let CI be the + full-suite gate. Never `npm run test:watch` (it never terminates). - The change is documented in the PR description (what + why + how to test). - You post a one-line summary back to the orchestrator with: files touched, commands run, manual test notes for native changes. diff --git a/.harness/reins/openscreen-tester/agent.md b/.harness/reins/openscreen-tester/agent.md index bd4658ce4a..aadbf7bbb7 100644 --- a/.harness/reins/openscreen-tester/agent.md +++ b/.harness/reins/openscreen-tester/agent.md @@ -9,7 +9,7 @@ You are the test specialist for the OpenScreen project — a free, open-source s ## Scope -- **Own**: Vitest unit tests (`*.test.ts` / `*.test.tsx`, jsdom), Vitest browser tests (`vitest.browser.config.ts`, Playwright headless), Playwright e2e (`tests/e2e/`). +- **Own**: Vitest unit tests (`*.test.ts` / `*.test.tsx`), Playwright e2e (`tests/e2e/`). - **Don't own**: writing production code (hand off to `openscreen-dev`). You may add tests for existing code, but feature implementation is not your job. Final PR quality gate is `openscreen-reviewer`. ## How you work @@ -17,16 +17,23 @@ You are the test specialist for the OpenScreen project — a free, open-source s - Read `AGENTS.md` at the repo root for commands and conventions. - Read `technical-documentation/testing/writing-tests.md` for the project's test style guide. - Match the style of neighboring `*.test.` files in the same package — don't invent new patterns. -- Unit tests: `npm run test` (Vitest, jsdom). Browser tests: `npm run test:browser` (needs `npm run test:browser:install` once). E2E: `npm run test:e2e` (Playwright). +- Iterate with `npx vitest --run ` on the files you are writing. `npm run test` is the + whole suite (minutes) — run it once at the end, not between edits. E2E: `npm run test:e2e`. +- The Vitest environment is `node` by default. A test that needs a DOM opts in with + `// @vitest-environment jsdom` on line 1 — add it only when the test actually renders. - E2E specs in `tests/e2e/windows-native-checklist.spec.ts` are Windows-only — gate with `test.skip` for other platforms rather than deleting. - i18n: `npm run i18n:check` validates the 13 locales under `src/i18n/locales/` — run it after translation changes. -- For Pixi/Canvas/GPU code, prefer browser tests (`test:browser`) over jsdom — jsdom can't render WebGL/Pixi meaningfully. +- jsdom can't render WebGL/Pixi meaningfully and there is no browser-test tier anymore. Real + codec/GPU behavior belongs to the Rust suites in `crates/` or to the manual checklist — + don't write a jsdom test that pretends to cover it. +- Anything gated on `process.platform` must pin the platform in the test. CI is Linux-only, + so an unpinned Linux-only path is green in CI and red on every Windows and macOS machine. - Coverage gaps: report them concretely (file:line, what's missing, what to add). Don't write the test for someone else's feature unprompted — flag it. ## Stop when -- `npm run test` passes. -- For browser-tested changes: `npm run test:browser` passes. +- The files you touched pass under `npx vitest --run `, and one final `npm run test` is + green (that single full run is the gate — not one per edit). - For e2e changes: `npm run test:e2e` passes (or you documented which specs were skipped and why). - `npm run i18n:check` passes if any locale file was touched. - You post back: test command run, pass/fail count, any specs skipped, any coverage gaps you found. diff --git a/AGENTS.md b/AGENTS.md index 5f270ef887..2d8e2d0491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,7 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R - Start dev: `npm run dev` (Vite dev server; Electron window opens via `vite-plugin-electron`) - Build: `npm run build` (TypeScript check + Vite build + electron-builder) - Typecheck: `npx tsc --noEmit` — app code only. CI also runs `npx tsc -p tsconfig.test.json --noEmit` in a separate job ("Typecheck (tests)"), so **run both**: test files are invisible to the root config, and a type error in a `*.test.ts` fails CI while the root check stays green. -- Test (unit): `npm run test` (Vitest, jsdom env) -- Test (browser): `npm run test:browser` (Vitest + Playwright, requires `npm run test:browser:install` first) +- Test (unit): `npx vitest --run ` while you work, `npm run test` once at the end — see [Testing instructions](#testing-instructions) - Test (e2e): `npm run test:e2e` (Playwright) - Lint: `npm run lint` (Biome 2.4) - Format: `npm run format` (Biome, tabs, double quotes, 100-col) @@ -37,11 +36,37 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R ## Testing instructions -- Unit tests live next to source as `*.test.ts` / `*.test.tsx` (Vitest, jsdom). -- Browser tests use `vitest.browser.config.ts` (Playwright headless) — only run when DOM/Pixi rendering matters. +### When to run what + +The full unit suite is ~1670 tests over 140 files and takes over a minute. Running it after +every edit is the main way an agent turns a 5-minute task into a 30-minute one, so don't: + +- **While you work** — run only what you touched: `npx vitest --run src/lib/foo.test.ts`, + or `npx vitest --run src/lib/ai-edition` for a directory. `npm run test:changed` picks + the affected files off the working tree, `npx vitest --run --changed main` off the + branch diff. A single file is 1–10s against ~80s for everything. +- **Typecheck and lint freely** — `npx tsc --noEmit` and `npm run lint` are seconds, not + minutes. They are the right inner-loop check, not the test suite. +- **Once, at the end** — `npm run test` before you commit or open the PR. One full run per + task, not per edit. If the change is narrow and CI will run anyway, the targeted run plus + CI is enough; say so rather than burning the wall-clock twice. +- **Never** `npm run test:watch` — it does not terminate, and it will hang the session. + +### Layout and conventions + +- Unit tests live next to source as `*.test.ts` / `*.test.tsx` (Vitest). Config is + `vitest.config.ts`; it covers `src/`, `electron/` and `.github/`. +- **The default environment is `node`.** A test that needs a DOM opts in with + `// @vitest-environment jsdom` on line 1 — that is also the fix for `document is not + defined`. Don't add it to a test that doesn't need it: jsdom setup dominates this + suite's runtime (see the comment in `vitest.config.ts`). +- Anything platform-conditional (`process.platform`) must pin the platform in the test. + CI is Linux-only, so a Linux-only code path left unpinned is green in CI and red on + every Windows and macOS machine — `electron/recording/webm-seek-index.test.ts` is the + worked example. - E2E tests are in `tests/e2e/` (Playwright). Some specs are platform-specific (e.g. `windows-native-checklist.spec.ts`). - Add a test for every new behavior in the same package as the code under test. -- All tests must pass before opening a PR. CI runs `npm run test` and `npm run test:browser` on every PR. +- All tests must pass before opening a PR. CI runs `npm run test` on every PR. ## Desktop E2E testing with computer-use diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 995044f753..30f5fd718e 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -387,7 +387,10 @@ describe("DocumentService", () => { expect(onDisk.annotations).toHaveLength(120); }); - it("survives many interleaved saves of one project", async () => { + // 20 real temp-file+rename round trips, serialized through the save queue. + // That is genuinely more than 5s of disk when the rest of the suite is + // running in parallel, so it gets its own timeout rather than flaking. + it("survives many interleaved saves of one project", { timeout: 20_000 }, async () => { const doc = await service.createProject("Storm"); // Sizes deliberately alternate long/short: equal-length writes overwrite // each other cleanly and would prove nothing. diff --git a/package.json b/package.json index fcff53ac65..d6e058fa01 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "build:whisper-binaries": "bash scripts/build-whisper-stt.sh", "test:whisper-stt": "node scripts/test-whisper-stt.mjs", "test": "vitest --run", + "test:changed": "vitest --run --changed", "wb": "vitest --run --config vitest.workbench.config.ts", "wb:l0": "vitest --run --config vitest.workbench.config.ts workbench/l0", "wb:watch": "vitest --config vitest.workbench.config.ts workbench/l0", diff --git a/src/components/ai-edition/CaptionsPane.gating.test.tsx b/src/components/ai-edition/CaptionsPane.gating.test.tsx index b1e87c92bb..cae76b59a4 100644 --- a/src/components/ai-edition/CaptionsPane.gating.test.tsx +++ b/src/components/ai-edition/CaptionsPane.gating.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // Captions are a view of the transcript, so the pane's "Transcribe video" // button is a retry, not a first step — the background pass has already tried. // On a media with no audio track that retry can only fail again, so the button diff --git a/src/components/ai-edition/ChatWelcome.test.tsx b/src/components/ai-edition/ChatWelcome.test.tsx index 28ccb4248d..8d835285ef 100644 --- a/src/components/ai-edition/ChatWelcome.test.tsx +++ b/src/components/ai-edition/ChatWelcome.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // ChatWelcome guards the "no provider connected" empty state: the copy reaches // the DOM, the CTA fires, and a non-English locale is really translated rather // than falling back to English. localeParity.test.ts covers key presence for diff --git a/src/components/ai-edition/ColorField.test.tsx b/src/components/ai-edition/ColorField.test.tsx index 84e6f771e3..636167367b 100644 --- a/src/components/ai-edition/ColorField.test.tsx +++ b/src/components/ai-edition/ColorField.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, describe, expect, it, vi } from "vitest"; diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx index e6ae692461..acb513e4b5 100644 --- a/src/components/ai-edition/EditorEmptyState.test.tsx +++ b/src/components/ai-edition/EditorEmptyState.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactElement } from "react"; diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index 97ae481c0b..9017569891 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -316,7 +316,12 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { action: { label: t("exportDialog.showInFolder"), onClick: () => { - void window.electronAPI?.revealInFolder?.(pickedPath); + // `revealInFolder` is a bare ipcRenderer.invoke, so it rejects when + // the main handler throws. The export already succeeded — failing to + // open the folder is not worth a second toast, but it is worth a line. + void window.electronAPI?.revealInFolder?.(pickedPath).catch((err) => { + console.warn("[export] failed to reveal the file in its folder:", err); + }); }, }, }); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index ee0a89e431..739ca3f546 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -1512,7 +1512,13 @@ export function SourceTranscriptModal({ const v = videoRef.current; if (!v) return; if (v.paused) { - void v.play(); + // Same catch as VirtualPreview's: `play()` rejects on the autoplay policy + // or when a new load interrupts it, and `isPlaying` is driven by the + // element's own play/pause events — so a rejection leaves nothing to + // reconcile, it just must not escape as an unhandled rejection. + void v.play().catch(() => { + // swallow: rejection just means playback never started + }); } else { v.pause(); } @@ -1528,7 +1534,11 @@ export function SourceTranscriptModal({ const requestFullscreen = () => { const v = videoRef.current; if (!v) return; - void v.requestFullscreen?.(); + // Rejects when the gesture isn't accepted or the element can't go fullscreen. + // Nothing to reconcile — the document stays as it was. + void v.requestFullscreen?.().catch(() => { + // swallow: rejection just means we stayed windowed + }); }; return ( diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 56a220d0bd..208942e27a 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -465,7 +465,13 @@ export function NewEditorShell() { const togglePlay = useCallback(() => { if (!videoElement) return; if (videoElement.paused) { - void videoElement.play(); + // Same catch as VirtualPreview's: `play()` rejects on the autoplay policy + // or when a new load interrupts it, and the store's `playing` flag is + // driven by the element's own play/pause listeners above — so a rejection + // leaves nothing to reconcile, it just must not escape unhandled. + void videoElement.play().catch(() => { + // swallow: rejection just means playback never started + }); } else { videoElement.pause(); } @@ -677,7 +683,9 @@ export function NewEditorShell() { } } if (action === "record") { - void window.electronAPI?.startNewRecording?.(); + void window.electronAPI?.startNewRecording?.().catch((err) => { + console.warn("[editor] failed to start a new recording:", err); + }); } resolve(choice); })(); @@ -688,7 +696,9 @@ export function NewEditorShell() { const handleNewRecording = useCallback(async () => { const choice = await promptUnsaved("record"); if (choice !== "cancel") { - void window.electronAPI?.startNewRecording?.(); + void window.electronAPI?.startNewRecording?.().catch((err) => { + console.warn("[editor] failed to start a new recording:", err); + }); } }, [promptUnsaved]); diff --git a/src/components/ai-edition/NewProjectModal.test.tsx b/src/components/ai-edition/NewProjectModal.test.tsx index 614479dd6e..03469939d0 100644 --- a/src/components/ai-edition/NewProjectModal.test.tsx +++ b/src/components/ai-edition/NewProjectModal.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; diff --git a/src/components/ai-edition/Preview.test.tsx b/src/components/ai-edition/Preview.test.tsx index d3498dfebe..294ad4c30a 100644 --- a/src/components/ai-edition/Preview.test.tsx +++ b/src/components/ai-edition/Preview.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/src/components/ai-edition/RightPanes.i18n.test.tsx b/src/components/ai-edition/RightPanes.i18n.test.tsx index 94c2ebf6a1..36cfd8c803 100644 --- a/src/components/ai-edition/RightPanes.i18n.test.tsx +++ b/src/components/ai-edition/RightPanes.i18n.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // Guards the right-rail settings panes against untranslated strings creeping // back in: every pane used to hardcode its English labels (title, tabs, slider // labels, help popover), so switching the app locale left the whole inspector diff --git a/src/components/ai-edition/TranscriptPane.gating.test.tsx b/src/components/ai-edition/TranscriptPane.gating.test.tsx index 354bbbb3cd..008c6161f6 100644 --- a/src/components/ai-edition/TranscriptPane.gating.test.tsx +++ b/src/components/ai-edition/TranscriptPane.gating.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // The transcript pane's empty state is what a user meets before any transcript // exists — and, since transcription now runs by itself in the background, it is // also where they wait for one. These assertions pin the three answers it has diff --git a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx index c55ae82330..600982fe0c 100644 --- a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx +++ b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // Backspace/Delete in the transcript pane, with the caret sitting BETWEEN words at editor // level. That is where `restoreCaretBeforeWord` parks it after every cut, so it is the // state the user is in when they hold Backspace to keep trimming — and the state where diff --git a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx index 9ce354646a..f111511224 100644 --- a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx +++ b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // The rendered half of the shared-media cue bug. `findCueWordId` returning the right // answer is not enough on its own: the cue id is compared against every rendered word, // so while `data-word-id` carried the bare `word.id`, the SAME transcript word projected diff --git a/src/components/ai-edition/TransportBar.test.tsx b/src/components/ai-edition/TransportBar.test.tsx index 517f94d978..4d29545fcf 100644 --- a/src/components/ai-edition/TransportBar.test.tsx +++ b/src/components/ai-edition/TransportBar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx index 5dce377851..f0389fe2a3 100644 --- a/src/components/ai-edition/VirtualPreview.playback.test.tsx +++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx index 9c49ec6f58..bdd36a5585 100644 --- a/src/components/ai-edition/WebcamOverlay.test.tsx +++ b/src/components/ai-edition/WebcamOverlay.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { cleanup, render } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/src/components/ai-edition/backgroundImageUpload.test.tsx b/src/components/ai-edition/backgroundImageUpload.test.tsx index d4b7f4607b..bf2d956941 100644 --- a/src/components/ai-edition/backgroundImageUpload.test.tsx +++ b/src/components/ai-edition/backgroundImageUpload.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // Cases recovered from the deleted `video-editor/backgroundImageUpload.test.ts`, whose // module was dropped as dead code in the 2026-07-26 reorg — correctly, since only its own // test imported it, but the empty-MIME fallback it encoded had never been wired into the diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index 0dade6d488..364674659a 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 57a5185f17..af8405711d 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -62,9 +62,16 @@ export function RecStage({ const [prefs, setPrefsState] = useState(DEFAULT_PREFS); useEffect(() => { let cancelled = false; - void window.electronAPI?.getRecordingPrefs?.().then((p) => { - if (!cancelled && p) setPrefsState(p as RecordingPrefsState); - }); + void window.electronAPI + ?.getRecordingPrefs?.() + .then((p) => { + if (!cancelled && p) setPrefsState(p as RecordingPrefsState); + }) + .catch((err) => { + // Bare ipcRenderer.invoke — rejects if the main handler throws. Keeping + // DEFAULT_PREFS is a fine outcome; an unhandled rejection is not. + console.warn("[rec-stage] failed to read the recording prefs:", err); + }); return () => { cancelled = true; }; @@ -72,7 +79,9 @@ export function RecStage({ const updatePrefs = (patch: Partial) => { setPrefsState((prev) => { const next = { ...prev, ...patch }; - void window.electronAPI?.setRecordingPrefs?.(patch); + void window.electronAPI?.setRecordingPrefs?.(patch).catch((err) => { + console.warn("[rec-stage] failed to persist the recording prefs:", err); + }); return next; }); }; @@ -112,7 +121,12 @@ export function RecStage({ // ── capture source (screen/window) ────────────────────────────── const [source, setSource] = useState(null); useEffect(() => { - void window.electronAPI?.getSelectedSource?.().then((s) => setSource(s ?? null)); + void window.electronAPI + ?.getSelectedSource?.() + .then((s) => setSource(s ?? null)) + .catch((err) => { + console.warn("[rec-stage] failed to read the selected source:", err); + }); }, []); const [sourceModalOpen, setSourceModalOpen] = useState(false); const [sourceTab, setSourceTab] = useState<"screen" | "window">("screen"); diff --git a/src/components/ai-edition/v4/SpeedControl.test.tsx b/src/components/ai-edition/v4/SpeedControl.test.tsx index c510227d7c..50a9673ab2 100644 --- a/src/components/ai-edition/v4/SpeedControl.test.tsx +++ b/src/components/ai-edition/v4/SpeedControl.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 490f4e0f4d..280853bef7 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, describe, expect, it, vi } from "vitest"; diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 12d177c148..76b6a6fe98 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/components/launch/NotesToolbar.test.tsx b/src/components/launch/NotesToolbar.test.tsx index 766e9c680e..21a813eb31 100644 --- a/src/components/launch/NotesToolbar.test.tsx +++ b/src/components/launch/NotesToolbar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; diff --git a/src/components/launch/NotesWindow.editable.test.tsx b/src/components/launch/NotesWindow.editable.test.tsx index c13000cb03..8f4dc8528e 100644 --- a/src/components/launch/NotesWindow.editable.test.tsx +++ b/src/components/launch/NotesWindow.editable.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; diff --git a/src/components/launch/NotesWindow.test.tsx b/src/components/launch/NotesWindow.test.tsx index d3f2120786..4c74d186e5 100644 --- a/src/components/launch/NotesWindow.test.tsx +++ b/src/components/launch/NotesWindow.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; diff --git a/src/components/launch/SourceSelector.test.tsx b/src/components/launch/SourceSelector.test.tsx index 82172e1cea..7868d85dad 100644 --- a/src/components/launch/SourceSelector.test.tsx +++ b/src/components/launch/SourceSelector.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/components/ui/gradient-editor.test.tsx b/src/components/ui/gradient-editor.test.tsx index 0d54a26eae..81be1c3ebf 100644 --- a/src/components/ui/gradient-editor.test.tsx +++ b/src/components/ui/gradient-editor.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/src/hooks/recorderHandle.test.ts b/src/hooks/recorderHandle.test.ts index 3f16437e3f..91d7154b99 100644 --- a/src/hooks/recorderHandle.test.ts +++ b/src/hooks/recorderHandle.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRecorderHandle } from "./recorderHandle"; diff --git a/src/hooks/useAudioPeaks.test.ts b/src/hooks/useAudioPeaks.test.ts index 77a850b788..0783cd2d92 100644 --- a/src/hooks/useAudioPeaks.test.ts +++ b/src/hooks/useAudioPeaks.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // Two properties that decide whether a long recording's waveform appears // quickly or not at all: which pipeline a file is routed to, and how many times // it is decoded. diff --git a/src/hooks/useCameraDevices.test.ts b/src/hooks/useCameraDevices.test.ts index e6ec8a9ed5..2bef6f046a 100644 --- a/src/hooks/useCameraDevices.test.ts +++ b/src/hooks/useCameraDevices.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCameraDevices } from "./useCameraDevices"; diff --git a/src/hooks/useScreenRecorder.nativeStopFailure.test.tsx b/src/hooks/useScreenRecorder.nativeStopFailure.test.tsx index 52c786be4d..928ac5886d 100644 --- a/src/hooks/useScreenRecorder.nativeStopFailure.test.tsx +++ b/src/hooks/useScreenRecorder.nativeStopFailure.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index a40fa3a31f..9724c27ef9 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -208,15 +208,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // defaults every time startNewRecording() switches to the HUD window. useEffect(() => { let cancelled = false; - void window.electronAPI?.getRecordingPrefs?.().then((prefs) => { - if (cancelled || !prefs) return; - setMicrophoneEnabled(prefs.micEnabled); - if (prefs.micDeviceId) setMicrophoneDeviceId(prefs.micDeviceId); - setWebcamEnabledState(prefs.camEnabled); - if (prefs.camDeviceId) setWebcamDeviceId(prefs.camDeviceId); - setSystemAudioEnabled(prefs.systemAudioEnabled); - setCursorCaptureMode(prefs.cursorCaptureMode); - }); + void window.electronAPI + ?.getRecordingPrefs?.() + .then((prefs) => { + if (cancelled || !prefs) return; + setMicrophoneEnabled(prefs.micEnabled); + if (prefs.micDeviceId) setMicrophoneDeviceId(prefs.micDeviceId); + setWebcamEnabledState(prefs.camEnabled); + if (prefs.camDeviceId) setWebcamDeviceId(prefs.camDeviceId); + setSystemAudioEnabled(prefs.systemAudioEnabled); + setCursorCaptureMode(prefs.cursorCaptureMode); + }) + .catch((err) => { + // Bare ipcRenderer.invoke — rejects if the main handler throws. Falling + // back to this hook's own defaults is acceptable; an unhandled rejection + // on every HUD mount is not. + console.warn("Failed to seed the recording prefs:", err); + }); return () => { cancelled = true; }; diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts index 278f8096dc..5483f2b880 100644 --- a/src/lib/ai-edition/store/projectStore.test.ts +++ b/src/lib/ai-edition/store/projectStore.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useProjectStore } from "./projectStore"; diff --git a/src/lib/ai-edition/store/transcriptionStore.test.ts b/src/lib/ai-edition/store/transcriptionStore.test.ts index d8208ddeff..03276bd254 100644 --- a/src/lib/ai-edition/store/transcriptionStore.test.ts +++ b/src/lib/ai-edition/store/transcriptionStore.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AxcutDocument, AxcutTranscript } from "../schema"; import { useProjectStore } from "./projectStore"; diff --git a/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts b/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts index 6ad4461cf1..f86088bcf2 100644 --- a/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts +++ b/src/lib/ai-edition/store/useSequentialTimelineOps.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom // ponytail: tests for the queued timeline-op hook. The hook used to live // inline in NewEditorShell.tsx (saveQueueRef + handleAddTrimRange / // handleRemoveTrimRange) and was untested; the bug it fixed (synchronous diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index ca1edf898d..7f45417687 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 28c499ed66..cd763bae14 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -933,7 +933,14 @@ export function useTimeline() { // Don't await — the drop is already responsive; the probe will // correct the clip when it lands. if (asset.durationSec == null) { - void probeAndCorrectClip(assetId, newClip.id, asset.originalPath); + // Detached on purpose (see above), so it needs its own handler: the probe + // itself only ever resolves, but it finishes with a `saveDocument`, and + // that THROWS on a failed write. Losing a background duration correction + // is survivable — the clip keeps its placeholder length; an unhandled + // rejection is not. + void probeAndCorrectClip(assetId, newClip.id, asset.originalPath).catch((err) => { + console.warn("[timeline] background duration probe failed to save:", err); + }); } }, [saveDocument, probeAndCorrectClip], diff --git a/src/lib/ai-edition/timeline/duration.test.ts b/src/lib/ai-edition/timeline/duration.test.ts index ba1dc3d111..528c3b9104 100644 --- a/src/lib/ai-edition/timeline/duration.test.ts +++ b/src/lib/ai-edition/timeline/duration.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { probeVideoDuration } from "./duration"; diff --git a/src/lib/ai-edition/timeline/pointer-drag.test.tsx b/src/lib/ai-edition/timeline/pointer-drag.test.tsx index 8ed7f5067b..05a4730dae 100644 --- a/src/lib/ai-edition/timeline/pointer-drag.test.tsx +++ b/src/lib/ai-edition/timeline/pointer-drag.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { startGlobalPointerDrag } from "./pointer-drag"; diff --git a/src/lib/captioning/transcribe.test.ts b/src/lib/captioning/transcribe.test.ts index 82e630b9ac..505fc59b08 100644 --- a/src/lib/captioning/transcribe.test.ts +++ b/src/lib/captioning/transcribe.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import { transcribeMono16kToSegments } from "./transcribe"; diff --git a/src/native/hooks/useCompositorBackend.test.ts b/src/native/hooks/useCompositorBackend.test.ts index e854108728..bb743a76a7 100644 --- a/src/native/hooks/useCompositorBackend.test.ts +++ b/src/native/hooks/useCompositorBackend.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom /** * The CPU-backend notice must fire on a degraded GPU and stay silent everywhere else. * diff --git a/src/native/hooks/useNativeCompositorView.test.ts b/src/native/hooks/useNativeCompositorView.test.ts index 8ef722c501..f3bf74ad09 100644 --- a/src/native/hooks/useNativeCompositorView.test.ts +++ b/src/native/hooks/useNativeCompositorView.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom /** * The fatal-error channel (PR #162). * diff --git a/src/utils/platformUtils.test.ts b/src/utils/platformUtils.test.ts index bdc7772e07..41c11b8f64 100644 --- a/src/utils/platformUtils.test.ts +++ b/src/utils/platformUtils.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, it } from "vitest"; import { getPlatform, isMac } from "./platformUtils"; diff --git a/technical-documentation/testing/writing-tests.md b/technical-documentation/testing/writing-tests.md index 69d1b5dda6..2e88886cee 100644 --- a/technical-documentation/testing/writing-tests.md +++ b/technical-documentation/testing/writing-tests.md @@ -1,23 +1,52 @@ # Writing Tests -This project uses Vitest for unit/integration tests and browser tests, plus Playwright for desktop end-to-end coverage. The configs select distinct file sets and execution environments. +This project uses Vitest for unit/integration tests, plus Playwright for desktop end-to-end coverage. ## Test types at a glance | Test type | Config | What it is for | Run it | |---|---|---|---| -| Unit / jsdom | `vitest.config.ts` | Logic, data transformations, React behavior, and integrations that do not require real browser media/graphics APIs. | `npm run test` or `npm run test:watch` | -| Browser / Playwright in Vitest | `vitest.browser.config.ts` | Source-level tests in real headless Chromium for Canvas, WebCodecs, MediaRecorder, WebGL, and related APIs. | `npm run test:browser:install`, then `npm run test:browser` | +| Unit | `vitest.config.ts` | Logic, data transformations, React behavior, and integrations that do not require real browser media/graphics APIs. | `npx vitest --run ` while working, `npm run test` once at the end | | End-to-end / Playwright | `playwright.config.ts` | Full workflows under `tests/e2e`, including Electron/native integration checklists and export flows. | `npm run test:e2e` | +> There used to be a third tier — "browser tests" in real headless Chromium via +> `vitest.browser.config.ts` and `npm run test:browser`, for `VideoDecoder`, +> `MediaRecorder`, `OffscreenCanvas` and WebGL. **It no longer exists**: no config, no +> script, no `*.browser.test.ts` file, and no CI job. Real-codec and real-GPU behavior is +> covered instead by the Rust compositor's own `cargo test` suites (`crates/`, run by the +> three `rust-*-compositor-check` CI jobs) and by the manual checklist below. + ## Unit tests **Config:** `vitest.config.ts` -**Runs in:** jsdom (simulated DOM, no real browser) -**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts` +**Runs in:** Node by default; jsdom only for files that ask for it +**File pattern:** `{src,electron,.github}/**/*.test.{ts,tsx}` **CI command:** `npm run test` -Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.). +Use unit tests for pure logic, utility functions, data transformations, and React behavior. + +### Environment: node by default, jsdom on request + +Building a jsdom for a test that never touches the DOM was this suite's single largest +cost — 719s of cumulative environment setup against 89s of actual test time. So +`vitest.config.ts` sets `environment: "node"`, and the 37 files that genuinely need a DOM +opt in with a docblock on line 1: + +```ts +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +``` + +That is also the fix when a new test dies on `document is not defined` or +`ReferenceError: window is not defined`. Every `*.test.tsx` needs it; a `*.test.ts` needs +it only if it renders a component, uses `renderHook`, or reaches for a browser global. + +### Platform-conditional code + +CI runs on Linux only. A test covering a `process.platform`-gated path must pin the +platform itself, or it passes vacuously in CI and fails on every Windows and macOS +machine. `electron/recording/webm-seek-index.test.ts` sets `process.platform` in +`beforeEach` and restores it in `afterEach` — copy that shape. ### File placement @@ -61,87 +90,18 @@ import { SUPPORTED_LOCALES } from "@/i18n/config"; ### Running locally -```bash -npm run test # run once -npm run test:watch # watch mode -``` - ---- - -## Browser tests - -**Config:** `vitest.browser.config.ts` -**Runs in:** real Chromium via Playwright (headless) -**File pattern:** `src/**/*.browser.test.ts` -**CI commands:** `npm run test:browser:install` then `npm run test:browser` - -Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc. - -### File placement - -Name the file `.browser.test.ts` and place it next to the source file. - -``` -src/lib/exporter/videoExporter.ts -src/lib/exporter/videoExporter.browser.test.ts -``` - -### Loading fixture assets - -Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server. - -```ts -import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; -``` - -### Example - -```ts -import { describe, expect, it } from "vitest"; -import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; -import { VideoExporter } from "./videoExporter"; - -describe("VideoExporter (real browser)", () => { - it("exports a valid MP4 blob from a real video", async () => { - const exporter = new VideoExporter({ - videoUrl: sampleVideoUrl, - width: 320, - height: 180, - frameRate: 15, - bitrate: 1_000_000, - wallpaper: "#1a1a2e", - zoomRegions: [], - showShadow: false, - shadowIntensity: 0, - showBlur: false, - cropRegion: { x: 0, y: 0, width: 1, height: 1 }, - }); - - const result = await exporter.export(); - - expect(result.success, result.error).toBe(true); - expect(result.blob).toBeInstanceOf(Blob); - }); -}); -``` - -### Timeouts - -Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast. - -### Running locally - -First install the browser (one-time): +The full run is ~1670 tests over 140 files and takes over a minute. Run it once, at the end of a +task — not after each edit. ```bash -npm run test:browser:install +npx vitest --run src/lib/foo.test.ts # one file, while you work (1-10s) +npx vitest --run src/lib/ai-edition # one directory +npm run test:changed # only what the working tree touches +npx vitest --run --changed main # only what the branch diff touches +npm run test # everything (~80s), once, before committing ``` -Then run the tests: - -```bash -npm run test:browser -``` +`npm run test:watch` never terminates — don't start it from a script or an agent session. --- @@ -149,11 +109,11 @@ npm run test:browser | Situation | Use | |---|---| -| Pure function / data transformation | Unit test | -| i18n key coverage | Unit test | -| React hook logic (no real browser APIs) | Unit test | -| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test | -| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test | -| File export producing a real `Blob` | Browser test | +| Pure function / data transformation | Unit test (node) | +| i18n key coverage | Unit test (node) | +| React component or hook behavior | Unit test + `// @vitest-environment jsdom` | +| `VideoDecoder` / `VideoEncoder` / real codecs | Rust test in `crates/`, or the manual checklist | +| WebGL / Pixi.js / GPU rendering | Rust test in `crates/`, or the manual checklist | +| A full export producing a real file | `tests/e2e/`, or the manual checklist | Automated suites do not exercise every hardware, permission, codec, signing, and packaged-app combination. Use the [manual end-to-end checklist](manual-e2e-checklist.md) for those release and platform checks. diff --git a/vitest.config.ts b/vitest.config.ts index e6b1497f93..d775a6e3f3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,9 +4,37 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: "jsdom", + // `node`, not `jsdom`. Building a jsdom for every test file was the single + // most expensive thing this suite did — 719s of cumulative environment setup + // against 89s of actual test time — and only 37 of the 140 files need a DOM + // at all. Those 37 opt back in with a `// @vitest-environment jsdom` docblock + // on line 1, which is also the fix when a new test dies on `document is not + // defined`. (`electron/media/audioPeaks.test.ts` already used the same + // docblock the other way round, to escape the global jsdom; that one is now + // redundant but harmless.) + environment: "node", include: ["{src,electron,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], - exclude: ["src/**/*.browser.test.{ts,tsx}"], + // Vitest's 5s default is too tight here and produces red runs that mean nothing. + // Measured: with the machine loaded, 11 tests fail and 9 of them are purely + // "Test timed out in 5000ms" — ordinary component tests that pass in 200ms on an + // idle box. A single jsdom file needs ~9.5s just to boot React, so 5s of budget + // is not a signal about the test. Raising this costs nothing on a passing run: + // the timeout only ever fires on a test that was going to fail anyway. + testTimeout: 15_000, + // Everything else that looks like a speedup here was measured and is NOT one. + // Full suite, same machine, back to back: 175s with jsdom everywhere, 81s with + // the split above. + // * `--no-isolate` ~20% faster, but it fails tests. Sharing one module + // registry per worker breaks `vi.mock`, and 29 test + // files rely on it (`@/native/client` alone is mocked + // in 10 of them, differently each time). Not worth + // making the suite's main mocking tool unsound. + // * `deps.optimizer.web` slower, not faster. + // * `--pool=threads` >5min, killed. jsdom in worker threads is pathological. + // * `--maxWorkers=16` inside the run-to-run noise on an 8-core box, and a + // number that would be wrong on any other machine. + // What is left is import cost: a jsdom file is ~9.5s alone, of which ~6.7s is + // pulling in React + testing-library and ~1.9s is the DOM itself. }, resolve: { alias: {