From 556918ac229156c4dec2c49f50373c15242a863f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 13:08:23 +0200 Subject: [PATCH 1/4] perf(test): run Vitest in node, and jsdom only where a DOM is needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite built a full jsdom for every one of its 140 test files. Only 37 of them ever touch a DOM, so the other 103 paid for one and threw it away: 719s of cumulative environment setup against 89s of actual test time. Flipping the default to `node` and letting the 37 opt back in with a `@vitest-environment` docblock takes the full run from 175s to 81s, back to back on the same machine, with no change to what any test asserts. The 37 were derived by running the suite under `--environment=node` and taking the files that failed, not by guessing from imports — 12 of them are `.ts` files (zustand stores, hooks, `platformUtils`) that a `.tsx`-only heuristic would have missed. `electron/media/audioPeaks.test.ts` already carried the same docblock the other way round to escape the global jsdom; that one is now redundant, and left alone. testTimeout goes to 15s in the same change because the two interact: with the machine loaded, 11 tests fail and 9 of those are purely "Test timed out in 5000ms" — ordinary component tests that pass in 200ms idle. A single jsdom file needs ~9.5s just to boot React, so a 5s budget was never a signal about the test. The one deliberately slow test (20 interleaved real disk writes) keeps its own longer override. The config comment also records the four things that look like speedups and are not — `--no-isolate`, `deps.optimizer.web`, `--pool=threads`, `--maxWorkers` — with what each measured, so the next person does not re-run that benchmark. `--no-isolate` is the tempting one: ~20% faster, but it shares one module registry per worker, which breaks `vi.mock`, and 29 test files depend on it. --- electron/ai-edition/document-service.test.ts | 5 ++- .../ai-edition/CaptionsPane.gating.test.tsx | 1 + .../ai-edition/ChatWelcome.test.tsx | 1 + src/components/ai-edition/ColorField.test.tsx | 1 + .../ai-edition/EditorEmptyState.test.tsx | 1 + .../ai-edition/NewProjectModal.test.tsx | 1 + src/components/ai-edition/Preview.test.tsx | 1 + .../ai-edition/RightPanes.i18n.test.tsx | 1 + .../ai-edition/TranscriptPane.gating.test.tsx | 1 + .../TranscriptPane.keyboardCut.test.tsx | 1 + .../TranscriptPane.sharedMedia.test.tsx | 1 + .../ai-edition/TransportBar.test.tsx | 1 + .../VirtualPreview.playback.test.tsx | 1 + .../ai-edition/WebcamOverlay.test.tsx | 1 + .../ai-edition/backgroundImageUpload.test.tsx | 1 + .../ai-edition/v4/EditorTopBar.test.tsx | 1 + .../ai-edition/v4/SpeedControl.test.tsx | 1 + .../v4/V4Timeline.geometry.test.tsx | 1 + src/components/launch/LaunchWindow.test.tsx | 1 + src/components/launch/NotesToolbar.test.tsx | 1 + .../launch/NotesWindow.editable.test.tsx | 1 + src/components/launch/NotesWindow.test.tsx | 1 + src/components/launch/SourceSelector.test.tsx | 1 + src/components/ui/gradient-editor.test.tsx | 1 + src/hooks/recorderHandle.test.ts | 1 + src/hooks/useAudioPeaks.test.ts | 1 + src/hooks/useCameraDevices.test.ts | 1 + ...eScreenRecorder.nativeStopFailure.test.tsx | 1 + src/lib/ai-edition/store/projectStore.test.ts | 1 + .../store/transcriptionStore.test.ts | 1 + .../store/useSequentialTimelineOps.test.ts | 1 + src/lib/ai-edition/store/useTimeline.test.ts | 1 + src/lib/ai-edition/timeline/duration.test.ts | 1 + .../ai-edition/timeline/pointer-drag.test.tsx | 1 + src/lib/captioning/transcribe.test.ts | 1 + src/native/hooks/useCompositorBackend.test.ts | 1 + .../hooks/useNativeCompositorView.test.ts | 1 + src/utils/platformUtils.test.ts | 1 + vitest.config.ts | 32 +++++++++++++++++-- 39 files changed, 71 insertions(+), 3 deletions(-) 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/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/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/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/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/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/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: { From 54f46c0a59beab6d52248bdfb14af57f10c55b75 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 13:09:39 +0200 Subject: [PATCH 2/4] docs(agents): run the tests you touched, not the whole suite each step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every agent-facing surface in the repo defined "done" as `npm run test` — the full suite. AGENTS.md, both harness reins and the git-workflow doc all said it, so an agent ran ~1670 tests after each edit and turned short tasks into long ones. They now say: targeted run while working (`npx vitest --run `, or the new `npm run test:changed`), tsc and biome as the inner loop since they are seconds, and one full run at the end or left to CI. Also removes a tier of tests that does not exist. `npm run test:browser` and `vitest.browser.config.ts` were documented across four files and 80 lines of writing-tests.md, with a worked example and a timeouts section — but there is no script, no config, no `*.browser.test.ts` file and no CI job for any of it. An agent reading that runs a command that cannot work. What actually covers real codecs and GPU is the Rust suites under `crates/` and the manual checklist, so the table now points there. The remaining testing docs pick up the two rules this branch had to learn the hard way: the environment is node unless a file opts into jsdom, and anything gated on `process.platform` has to pin it, because CI is Linux-only and an unpinned Linux-only path is green there and red on every other machine. --- .harness/docs/git-workflow.md | 12 +- .harness/reins/openscreen-dev/agent.md | 4 +- .harness/reins/openscreen-tester/agent.md | 17 ++- AGENTS.md | 35 ++++- package.json | 1 + .../testing/writing-tests.md | 138 +++++++----------- 6 files changed, 102 insertions(+), 105 deletions(-) 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/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/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. From 2e7d3610c0bd71faff508d990407c5378580c91e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 13:09:54 +0200 Subject: [PATCH 3/4] fix(ci-scripts): clear the thread-validation timeout when fetch rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clearTimeout` sat on the line after the `await`, so only the success path ever reached it. When `fetch` rejects — a real network error, and the case the test file already drives — control jumped straight to `catch` and the 5s abort timer stayed armed, firing `controller.abort()` long after the function returned. `callDiscord` in the sibling `discord-bot-api.mjs` already had this right with a `try`/`finally`; this is the same shape. The new test pins it with fake timers on both the rejecting and the non-ok path: reverting the `finally` makes it fail with `expected 1 to be +0`. --- .github/scripts/discord-thread-validator.mjs | 12 +++++++++--- .../scripts/discord-thread-validator.test.mjs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) 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(); + } + }); }); From a205d593a7ba4f8721f6276b39f216b4e73847c3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 13:10:18 +0200 Subject: [PATCH 4/4] fix(renderer): handle the detached promises that can actually reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of every `void ` in src/ and electron/ after 2c4c4267 fixed one of them, since `void` marks a promise as intentionally detached but does nothing about its rejection. 122 sites; most are fine and are left alone. The three in electron/ are the ones that can kill the app, because `installMainProcessErrorGuards` re-throws every unhandled rejection whose code is not EPIPE/ECONNRESET/ERR_STREAM_DESTROYED. One was the registry write fixed in 2c4c4267; the other two are already correct — `document-service` voids a promise that is already `.catch()`ed, and `cliMain`'s chain ends in a `.catch` that exits non-zero. Neither gets ceremony added. In the renderer a rejection is console noise rather than a crash, so the fixes here are the sites where a rejection is not hypothetical: * `play()` and `requestFullscreen()` reject routinely (autoplay policy, a load interrupting a pending play). VirtualPreview and WebcamOverlay already caught theirs; Modals and NewEditorShell did not. Play state is driven by the element's own play/pause events in both, so a rejection leaves nothing to reconcile — same commented swallow as the existing two. * Bare `ipcRenderer.invoke` calls reject when the main handler throws: revealInFolder, startNewRecording, and the recording-prefs and selected-source reads. These log, because a failed IPC means something is genuinely broken. * The background duration probe in `useTimeline` ends in `saveDocument`, which throws on a failed write — the same shape as the registry bug. Not fixed here, deliberately: 12 sites where a user-initiated timeline mutation (removeRegion, removeClip, duplicateClip, insertClipAt, applyTimelineOp) can fail silently, because neither `useTimeline` nor `useSequentialTimelineOps` catches anything. Twelve scattered `console.warn`s would bury a real user-facing failure; they all route through two functions, so the fix belongs there with a toast — which is a UX change, not an audit cleanup. `handleSave` already does exactly that, so the pattern to copy is next door. --- src/components/ai-edition/ExportDialog.tsx | 7 +++++- src/components/ai-edition/Modals.tsx | 14 +++++++++-- src/components/ai-edition/NewEditorShell.tsx | 16 +++++++++--- src/components/ai-edition/v4/RecStage.tsx | 24 ++++++++++++++---- src/hooks/useScreenRecorder.ts | 26 +++++++++++++------- src/lib/ai-edition/store/useTimeline.ts | 9 ++++++- 6 files changed, 75 insertions(+), 21 deletions(-) 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/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/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/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],