diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41947974a..d0ed60fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,14 +44,37 @@ jobs: - run: npm run test:browser:install - run: npm run test:browser - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - - run: npm ci - - run: npx vite build + - run: npm ci + - run: npx vite build + + electron-e2e: + name: Electron E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build-vite + - run: npx playwright install --with-deps + - run: xvfb-run -a npm run test:e2e + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: electron-e2e-artifacts + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index dc9758fbd..f14be62f2 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -408,6 +408,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }); + const safeHideCountdownOverlay = useCallback(async (runId: number) => { + try { + await window.electronAPI.hideCountdownOverlay(runId); + } catch (error) { + console.warn("Failed to hide countdown overlay:", error); + } + }, []); + useEffect(() => { let cleanup: (() => void) | undefined; @@ -450,7 +458,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamRecorder.current = null; teardownMedia(); }; - }, [teardownMedia]); + }, [teardownMedia, safeHideCountdownOverlay]); const safeShowCountdownOverlay = async (value: number, runId: number) => { try { @@ -477,14 +485,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; - const safeHideCountdownOverlay = async (runId: number) => { - try { - await window.electronAPI.hideCountdownOverlay(runId); - } catch (error) { - console.warn("Failed to hide countdown overlay:", error); - } - }; - const isCountdownRunActive = (runId?: number) => runId === undefined || countdownRunId.current === runId; diff --git a/tests/e2e/gif-export.spec.ts b/tests/e2e/gif-export.spec.ts index d1fa3f7aa..890a9bdd3 100644 --- a/tests/e2e/gif-export.spec.ts +++ b/tests/e2e/gif-export.spec.ts @@ -2,13 +2,59 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { _electron as electron, expect, test } from "@playwright/test"; +import { type ElectronApplication, _electron as electron, expect, test } from "@playwright/test"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, "../.."); const MAIN_JS = path.join(ROOT, "dist-electron/main.js"); const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm"); +async function waitForProcessExit( + child: ReturnType, + timeoutMs: number, +) { + if (child.exitCode !== null) return true; + + return new Promise((resolve) => { + let timer: ReturnType; + const cleanup = () => { + clearTimeout(timer); + child.removeListener("exit", onExit); + }; + const finish = (exited: boolean) => { + cleanup(); + resolve(exited); + }; + const onExit = () => finish(true); + const onTimeout = () => finish(false); + + timer = setTimeout(onTimeout, timeoutMs); + child.once("exit", onExit); + if (child.exitCode !== null) finish(true); + }); +} + +async function closeElectronApp(app: ElectronApplication) { + const child = app.process(); + await app + .evaluate(({ app: electronApp }) => { + electronApp.exit(0); + }) + .catch(() => { + // App may already be closing. + }); + const exited = await waitForProcessExit(child, 2_000); + if (child.exitCode === null) { + child.kill("SIGKILL"); + const killed = await waitForProcessExit(child, 2_000); + if (!killed) { + throw new Error("Electron process did not exit after SIGKILL"); + } + } else if (!exited) { + throw new Error("Electron process exit timed out"); + } +} + test("exports a GIF from a loaded video", async () => { const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`); let testVideoInRecordings = ""; @@ -126,7 +172,7 @@ test("exports a GIF from a loaded video", async () => { const stats = fs.statSync(outputPath); expect(stats.size).toBeGreaterThan(1024); // at least 1 KB } finally { - await app.close(); + await closeElectronApp(app); if (fs.existsSync(outputPath)) { fs.unlinkSync(outputPath); }