Merge pull request #223 from marcgabe15/marcdiaz/e2e

E2E Testing with Playwright
This commit is contained in:
Sid
2026-03-16 20:59:04 -07:00
committed by GitHub
10 changed files with 231 additions and 1 deletions
+23
View File
@@ -42,3 +42,26 @@ jobs:
cache: npm
- run: npm ci
- run: npx vite build
e2e:
name: E2E Tests
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 playwright install --with-deps chromium
# Install Electron system dependencies not covered by Playwright's chromium deps
- run: npx electron . --version || sudo apt-get install -y libgbm-dev
- run: npm run build-vite
# xvfb provides a virtual display; Electron needs one on Linux even with show:false
- run: xvfb-run --auto-servernum npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+5 -1
View File
@@ -25,4 +25,8 @@ dist-ssr
*.sw?
release/**
*.kiro/
# npx electron-builder --mac --win
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
+3
View File
@@ -7,6 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST = path.join(APP_ROOT, "dist");
const HEADLESS = process.env["HEADLESS"] === "true";
let hudOverlayWindow: BrowserWindow | null = null;
@@ -41,6 +42,7 @@ export function createHudOverlayWindow(): BrowserWindow {
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
show: !HEADLESS,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
@@ -90,6 +92,7 @@ export function createEditorWindow(): BrowserWindow {
skipTaskbar: false,
title: "OpenScreen",
backgroundColor: "#000000",
show: !HEADLESS,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
+60
View File
@@ -51,6 +51,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
@@ -2942,6 +2943,21 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -10892,6 +10908,50 @@
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
+3
View File
@@ -15,6 +15,8 @@
"build:linux": "tsc && vite build && electron-builder --linux",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
"test:e2e": "playwright test",
"prepare": "husky"
},
"dependencies": {
@@ -61,6 +63,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 120_000, // GIF encoding is CPU-bound; give it room
retries: 0,
reporter: "list",
});
@@ -33,6 +33,7 @@ import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
@@ -968,6 +969,7 @@ export function SettingsPanel({
MP4
</button>
<button
data-testid={getTestId("gif-format-button")}
onClick={() => onExportFormatChange?.("gif")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
@@ -1042,6 +1044,7 @@ export function SettingsPanel({
{Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => (
<button
key={key}
data-testid={getTestId(`gif-size-button-${key}`)}
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
@@ -1093,6 +1096,7 @@ export function SettingsPanel({
</div>
<Button
data-testid={getTestId("export-button")}
type="button"
size="lg"
onClick={onExport}
+5
View File
@@ -0,0 +1,5 @@
export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`;
export function getTestId(testId: TestId) {
return `testId-${testId}`;
}
+120
View File
@@ -0,0 +1,120 @@
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";
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");
test("exports a GIF from a loaded video", async () => {
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
const app = await electron.launch({
args: [
MAIN_JS,
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
"--no-sandbox",
],
env: {
...process.env,
// Set HEADLESS=false to show windows while debugging.
HEADLESS: process.env["HEADLESS"] ?? "true",
},
});
// Print all main-process stdout/stderr so failures are diagnosable.
app.process().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`));
app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));
try {
// ── 1. Wait for the HUD overlay window. The window is created after
// registerIpcHandlers() completes, so all IPC handlers are live
// by the time firstWindow() resolves.
const hudWindow = await app.firstWindow({ timeout: 60_000 });
await hudWindow.waitForLoadState("domcontentloaded");
// ── 2. Intercept the native save dialog in the main process.
// Must happen after firstWindow() so registerIpcHandlers() has
// already registered its version — otherwise our early handle()
// call causes registerIpcHandlers() to throw and abort, leaving
// other handlers (like set-current-video-path) never registered.
// Store the exported buffer as a base64 global in the main process.
// We can't use require() or import() inside app.evaluate() because the
// main process is ESM and Playwright runs the callback via eval(), which
// has no dynamic-import hook. We retrieve and write the file below after
// the export finishes.
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler("save-exported-video");
ipcMain.handle(
"save-exported-video",
(_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer) => {
(globalThis as Record<string, unknown>)["__testExportData"] =
Buffer.from(buffer).toString("base64");
return { success: true, path: "pending" };
},
);
});
await hudWindow.evaluate((videoPath: string) => {
window.electronAPI.setCurrentVideoPath(videoPath);
try {
window.electronAPI.switchToEditor();
} catch {
// Expected: HUD window closes during this call, killing the context.
}
}, TEST_VIDEO);
// ── 3. Switch to the editor window. This closes the HUD and opens
// a new BrowserWindow with ?windowType=editor.
const editorWindow = await app.waitForEvent("window", {
predicate: (w) => w.url().includes("windowType=editor"),
timeout: 15_000,
});
// WebCodecs (VideoEncoder) may not be registered in the renderer on first
// load of a second BrowserWindow. A single reload ensures the feature is
// fully initialized before we start encoding.
await editorWindow.reload();
await editorWindow.waitForLoadState("domcontentloaded");
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({
timeout: 15_000,
});
// ── 5. Select GIF as the export format.
await editorWindow.getByTestId("testId-gif-format-button").click();
await editorWindow.getByTestId("testId-export-button").click();
// ── 6. Wait for the toast to say exported successfully
await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({
timeout: 90_000,
});
// ── 7. Write the captured buffer from the main-process global to disk.
const base64 = await app.evaluate(
() => (globalThis as Record<string, unknown>)["__testExportData"] as string,
);
fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
// ── 8. Verify the file on disk is a valid GIF.
expect(fs.existsSync(outputPath), `GIF not found at ${outputPath}`).toBe(true);
const header = Buffer.alloc(6);
const fd = fs.openSync(outputPath, "r");
fs.readSync(fd, header, 0, 6, 0);
fs.closeSync(fd);
// GIF magic bytes are either "GIF87a" or "GIF89a"
expect(header.toString("ascii")).toMatch(/^GIF8[79]a/);
const stats = fs.statSync(outputPath);
expect(stats.size).toBeGreaterThan(1024); // at least 1 KB
} finally {
await app.close();
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
}
});
BIN
View File
Binary file not shown.