Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export default defineConfig({
testDir: "./tests/e2e",
timeout: 120_000, // GIF encoding is CPU-bound; give it room
retries: 0,
workers: 1,
reporter: "list",
});
1 change: 1 addition & 0 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1466,6 +1466,7 @@ export function SettingsPanel({
<div className="flex-shrink-0 p-4 pt-3 border-t border-white/5 bg-[#09090b]">
<div className="flex items-center gap-2 mb-3">
<button
data-testid={getTestId("mp4-format-button")}
onClick={() => 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",
Expand Down
18 changes: 9 additions & 9 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -450,7 +458,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current = null;
teardownMedia();
};
}, [teardownMedia]);
}, [teardownMedia, safeHideCountdownOverlay]);

const safeShowCountdownOverlay = async (value: number, runId: number) => {
try {
Expand All @@ -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;

Expand Down
6 changes: 5 additions & 1 deletion src/utils/getTestId.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`;
export type TestId =
| `gif-size-button-${string}`
| "export-button"
| "gif-format-button"
| "mp4-format-button";

export function getTestId(testId: TestId) {
return `testId-${testId}`;
Expand Down
33 changes: 10 additions & 23 deletions tests/e2e/gif-export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ 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 {
closeElectronApp,
copyFixtureToRecordings,
interceptExportSave,
readCapturedExportBuffer,
} from "./helpers";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "../..");
Expand Down Expand Up @@ -49,27 +55,11 @@ test("exports a GIF from a loaded video", async () => {
// 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 interceptExportSave(app);

// 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");
});
const recordingsDir = path.join(userDataDir, "recordings");
testVideoInRecordings = path.join(recordingsDir, "test-sample.webm");
fs.mkdirSync(recordingsDir, { recursive: true });
fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
testVideoInRecordings = await copyFixtureToRecordings(app, TEST_VIDEO, "test-sample.webm");

try {
await hudWindow.evaluate((videoPath: string) => {
Expand Down Expand Up @@ -107,10 +97,7 @@ test("exports a GIF from a loaded video", async () => {
});

// ── 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"));
fs.writeFileSync(outputPath, await readCapturedExportBuffer(app));

// ── 8. Verify the file on disk is a valid GIF.
expect(fs.existsSync(outputPath), `GIF not found at ${outputPath}`).toBe(true);
Expand All @@ -126,7 +113,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);
}
Expand Down
70 changes: 70 additions & 0 deletions tests/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import fs from "node:fs";
import path from "node:path";
import type { ElectronApplication } from "@playwright/test";

export async function waitForProcessExit(
child: ReturnType<ElectronApplication["process"]>,
timeoutMs: number,
) {
if (child.exitCode !== null || child.killed) return;

await Promise.race([
new Promise<void>((resolve) => child.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}

export async function closeElectronApp(app: ElectronApplication) {
const child = app.process();
await app
.evaluate(({ app: electronApp }) => {
electronApp.exit(0);
})
.catch(() => {
// App may already be closing.
});
await waitForProcessExit(child, 2_000);
if (child.exitCode === null && !child.killed) {
child.kill("SIGKILL");
await waitForProcessExit(child, 2_000);
}
}

export async function interceptExportSave(app: ElectronApplication) {
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" };
},
);
});
}

export async function copyFixtureToRecordings(
app: ElectronApplication,
fixturePath: string,
fileName: string,
) {
const userDataDir = await app.evaluate(({ app: electronApp }) => {
return electronApp.getPath("userData");
});
const recordingsDir = path.join(userDataDir, "recordings");
const targetPath = path.join(recordingsDir, fileName);
fs.mkdirSync(recordingsDir, { recursive: true });
fs.copyFileSync(fixturePath, targetPath);
return targetPath;
}

export async function readCapturedExportBuffer(app: ElectronApplication) {
const base64 = await app.evaluate(
() => (globalThis as Record<string, unknown>)["__testExportData"] as string,
);
if (typeof base64 !== "string" || base64.length === 0) {
throw new Error("__testExportData was not set or is invalid");
}
return Buffer.from(base64, "base64");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
86 changes: 86 additions & 0 deletions tests/e2e/mp4-export.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 {
closeElectronApp,
copyFixtureToRecordings,
interceptExportSave,
readCapturedExportBuffer,
} from "./helpers";

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 an MP4 from a loaded video", async () => {
const outputPath = path.join(os.tmpdir(), `test-mp4-export-${Date.now()}.mp4`);
let testVideoInRecordings = "";

const app = await electron.launch({
args: [MAIN_JS, "--no-sandbox", "--enable-unsafe-swiftshader"],
env: {
...process.env,
HEADLESS: process.env["HEADLESS"] ?? "true",
},
});

app.process().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`));
app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));

try {
const hudWindow = await app.firstWindow({ timeout: 60_000 });
await hudWindow.waitForLoadState("domcontentloaded");
await interceptExportSave(app);

testVideoInRecordings = await copyFixtureToRecordings(app, TEST_VIDEO, "test-sample-mp4.webm");

try {
await hudWindow.evaluate(async (videoPath: string) => {
await window.electronAPI.setCurrentVideoPath(videoPath);
await window.electronAPI.switchToEditor();
}, testVideoInRecordings);
} catch {
// Expected: switchToEditor closes the HUD window.
}

const editorWindow = await app.waitForEvent("window", {
predicate: (w) => w.url().includes("windowType=editor"),
timeout: 15_000,
});

await editorWindow.reload();
await editorWindow.waitForLoadState("domcontentloaded");
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({
timeout: 15_000,
});

await editorWindow.getByTestId("testId-mp4-format-button").click();
await editorWindow.getByTestId("testId-export-button").click();

await expect(editorWindow.getByText("Video exported successfully")).toBeVisible({
timeout: 90_000,
});

fs.writeFileSync(outputPath, await readCapturedExportBuffer(app));
expect(fs.existsSync(outputPath), `MP4 not found at ${outputPath}`).toBe(true);

const header = Buffer.alloc(12);
const fd = fs.openSync(outputPath, "r");
fs.readSync(fd, header, 0, 12, 0);
fs.closeSync(fd);

expect(header.subarray(4, 8).toString("ascii")).toBe("ftyp");
expect(fs.statSync(outputPath).size).toBeGreaterThan(1024);
} finally {
await closeElectronApp(app);
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
fs.unlinkSync(testVideoInRecordings);
}
}
});
Loading