Merge pull request #223 from marcgabe15/marcdiaz/e2e
E2E Testing with Playwright
This commit is contained in:
@@ -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
@@ -25,4 +25,8 @@ dist-ssr
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
# npx electron-builder --mac --win
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
@@ -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,
|
||||
|
||||
Generated
+60
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`;
|
||||
|
||||
export function getTestId(testId: TestId) {
|
||||
return `testId-${testId}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user