diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 96006c2..b0b46df 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -808,6 +808,7 @@ export function SettingsPanel({ setActivePanelMode(exportPanelMode.id)} @@ -1792,6 +1793,7 @@ export function SettingsPanel({ <> onExportFormatChange?.("mp4")} className={cn( "flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium", diff --git a/src/utils/getTestId.ts b/src/utils/getTestId.ts index 453f7c9..07454e4 100644 --- a/src/utils/getTestId.ts +++ b/src/utils/getTestId.ts @@ -1,4 +1,9 @@ -export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`; +export type TestId = + | `gif-size-button-${string}` + | "export-button" + | "export-panel-button" + | "gif-format-button" + | "mp4-format-button"; export function getTestId(testId: TestId) { return `testId-${testId}`; diff --git a/tests/e2e/gif-export.spec.ts b/tests/e2e/gif-export.spec.ts index d1fa3f7..18277cc 100644 --- a/tests/e2e/gif-export.spec.ts +++ b/tests/e2e/gif-export.spec.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,8 +10,8 @@ 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`); +async function exportFromLoadedVideo(format: "gif" | "mp4"): Promise { + const outputPath = path.join(os.tmpdir(), `test-${format}-export-${Date.now()}.${format}`); let testVideoInRecordings = ""; const app = await electron.launch({ @@ -27,42 +28,39 @@ test("exports a GIF from a loaded video", async () => { HEADLESS: process.env["HEADLESS"] ?? "true", }, }); + const electronProcess = app.process(); - // 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"); + await app.evaluate(({ ipcMain }, targetPath: string) => { + ipcMain.removeHandler("pick-export-save-path"); + ipcMain.removeHandler("write-export-to-path"); + ipcMain.handle("pick-export-save-path", () => ({ + success: true, + path: targetPath, + canceled: false, + })); ipcMain.handle( - "save-exported-video", - (_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer) => { + "write-export-to-path", + (_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer, filePath: string) => { + if (filePath !== targetPath) { + return { + success: false, + error: `Unexpected export path: ${filePath}`, + }; + } (globalThis as Record)["__testExportData"] = Buffer.from(buffer).toString("base64"); - return { success: true, path: "pending" }; + return { success: true, path: filePath }; }, ); - }); + }, outputPath); - // Copy the test fixture into the app's recordings directory so it passes - // the path security check in set-current-video-path. const userDataDir = await app.evaluate(({ app: electronApp }) => { return electronApp.getPath("userData"); }); @@ -71,62 +69,73 @@ test("exports a GIF from a loaded video", async () => { fs.mkdirSync(recordingsDir, { recursive: true }); fs.copyFileSync(TEST_VIDEO, testVideoInRecordings); + await hudWindow.evaluate( + (videoPath: string) => window.electronAPI.setCurrentVideoPath(videoPath), + testVideoInRecordings, + ); try { - await hudWindow.evaluate((videoPath: string) => { - window.electronAPI.setCurrentVideoPath(videoPath); - window.electronAPI.switchToEditor(); - }, testVideoInRecordings); - } catch { - // Expected: switchToEditor() closes the HUD window, terminating - // the Playwright page context before evaluate() can resolve. + await hudWindow.evaluate(() => window.electronAPI.switchToEditor()); + } catch (error) { + if ( + !(error instanceof Error) || + !/closed|destroyed|target page|target closed/i.test(error.message) + ) { + throw error; + } } - // ── 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. + // WebCodecs may not be registered in the renderer on first load. 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-panel-button").click(); + await editorWindow.getByTestId(`testId-${format}-format-button`).click(); await editorWindow.getByTestId("testId-export-button").click(); - // ── 6. Wait for the success toast. - await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({ - timeout: 90_000, - }); + await expect + .poll( + () => + app.evaluate(() => Boolean((globalThis as Record)["__testExportData"])), + { timeout: 90_000 }, + ) + .toBe(true); - // ── 7. Write the captured buffer from the main-process global to disk. const base64 = await app.evaluate( () => (globalThis as Record)["__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/); - + expect(fs.existsSync(outputPath), `${format.toUpperCase()} not found at ${outputPath}`).toBe( + true, + ); const stats = fs.statSync(outputPath); - expect(stats.size).toBeGreaterThan(1024); // at least 1 KB + expect(stats.size).toBeGreaterThan(1024); + return fs.readFileSync(outputPath); } finally { - await app.close(); + await app + .evaluate(({ app: electronApp }) => { + electronApp.exit(0); + }) + .catch(() => { + // The process may already be gone after export completes. + }); + if (electronProcess.pid) { + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(electronProcess.pid), "/T", "/F"], { + stdio: "ignore", + }); + } else if (!electronProcess.killed) { + electronProcess.kill("SIGKILL"); + } + } if (fs.existsSync(outputPath)) { fs.unlinkSync(outputPath); } @@ -134,4 +143,16 @@ test("exports a GIF from a loaded video", async () => { fs.unlinkSync(testVideoInRecordings); } } +} + +test("exports an MP4 from a loaded video", async () => { + const exported = await exportFromLoadedVideo("mp4"); + + expect(exported.subarray(4, 8).toString("ascii")).toBe("ftyp"); +}); + +test("exports a GIF from a loaded video", async () => { + const exported = await exportFromLoadedVideo("gif"); + + expect(exported.subarray(0, 6).toString("ascii")).toMatch(/^GIF8[79]a/); });