Merge pull request #603 from AjTheSpidey/codex/multi-source-recording-editor
test: cover MP4 editor export
This commit is contained in:
@@ -808,6 +808,7 @@ export function SettingsPanel({
|
|||||||
<Crop className="h-4 w-4" />
|
<Crop className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
data-testid={getTestId("export-panel-button")}
|
||||||
type="button"
|
type="button"
|
||||||
title={exportPanelMode.label}
|
title={exportPanelMode.label}
|
||||||
onClick={() => setActivePanelMode(exportPanelMode.id)}
|
onClick={() => setActivePanelMode(exportPanelMode.id)}
|
||||||
@@ -1792,6 +1793,7 @@ export function SettingsPanel({
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
|
data-testid={getTestId("mp4-format-button")}
|
||||||
onClick={() => onExportFormatChange?.("mp4")}
|
onClick={() => onExportFormatChange?.("mp4")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
|
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
|
||||||
|
|||||||
@@ -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) {
|
export function getTestId(testId: TestId) {
|
||||||
return `testId-${testId}`;
|
return `testId-${testId}`;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 MAIN_JS = path.join(ROOT, "dist-electron/main.js");
|
||||||
const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
|
const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
|
||||||
|
|
||||||
test("exports a GIF from a loaded video", async () => {
|
async function exportFromLoadedVideo(format: "gif" | "mp4"): Promise<Buffer> {
|
||||||
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
|
const outputPath = path.join(os.tmpdir(), `test-${format}-export-${Date.now()}.${format}`);
|
||||||
let testVideoInRecordings = "";
|
let testVideoInRecordings = "";
|
||||||
|
|
||||||
const app = await electron.launch({
|
const app = await electron.launch({
|
||||||
@@ -27,42 +28,39 @@ test("exports a GIF from a loaded video", async () => {
|
|||||||
HEADLESS: process.env["HEADLESS"] ?? "true",
|
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().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`));
|
||||||
app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));
|
app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));
|
||||||
|
|
||||||
try {
|
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 });
|
const hudWindow = await app.firstWindow({ timeout: 60_000 });
|
||||||
await hudWindow.waitForLoadState("domcontentloaded");
|
await hudWindow.waitForLoadState("domcontentloaded");
|
||||||
|
|
||||||
// ── 2. Intercept the native save dialog in the main process.
|
await app.evaluate(({ ipcMain }, targetPath: string) => {
|
||||||
// Must happen after firstWindow() so registerIpcHandlers() has
|
ipcMain.removeHandler("pick-export-save-path");
|
||||||
// already registered its version — otherwise our early handle()
|
ipcMain.removeHandler("write-export-to-path");
|
||||||
// call causes registerIpcHandlers() to throw and abort, leaving
|
ipcMain.handle("pick-export-save-path", () => ({
|
||||||
// other handlers (like set-current-video-path) never registered.
|
success: true,
|
||||||
// Store the exported buffer as a base64 global in the main process.
|
path: targetPath,
|
||||||
// We can't use require() or import() inside app.evaluate() because the
|
canceled: false,
|
||||||
// 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(
|
ipcMain.handle(
|
||||||
"save-exported-video",
|
"write-export-to-path",
|
||||||
(_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer) => {
|
(_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer, filePath: string) => {
|
||||||
|
if (filePath !== targetPath) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Unexpected export path: ${filePath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
(globalThis as Record<string, unknown>)["__testExportData"] =
|
(globalThis as Record<string, unknown>)["__testExportData"] =
|
||||||
Buffer.from(buffer).toString("base64");
|
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 }) => {
|
const userDataDir = await app.evaluate(({ app: electronApp }) => {
|
||||||
return electronApp.getPath("userData");
|
return electronApp.getPath("userData");
|
||||||
});
|
});
|
||||||
@@ -71,62 +69,73 @@ test("exports a GIF from a loaded video", async () => {
|
|||||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||||
fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
|
fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
|
||||||
|
|
||||||
|
await hudWindow.evaluate(
|
||||||
|
(videoPath: string) => window.electronAPI.setCurrentVideoPath(videoPath),
|
||||||
|
testVideoInRecordings,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await hudWindow.evaluate((videoPath: string) => {
|
await hudWindow.evaluate(() => window.electronAPI.switchToEditor());
|
||||||
window.electronAPI.setCurrentVideoPath(videoPath);
|
} catch (error) {
|
||||||
window.electronAPI.switchToEditor();
|
if (
|
||||||
}, testVideoInRecordings);
|
!(error instanceof Error) ||
|
||||||
} catch {
|
!/closed|destroyed|target page|target closed/i.test(error.message)
|
||||||
// Expected: switchToEditor() closes the HUD window, terminating
|
) {
|
||||||
// the Playwright page context before evaluate() can resolve.
|
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", {
|
const editorWindow = await app.waitForEvent("window", {
|
||||||
predicate: (w) => w.url().includes("windowType=editor"),
|
predicate: (w) => w.url().includes("windowType=editor"),
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebCodecs (VideoEncoder) may not be registered in the renderer on first
|
// WebCodecs may not be registered in the renderer on first load.
|
||||||
// load of a second BrowserWindow. A single reload ensures the feature is
|
|
||||||
// fully initialized before we start encoding.
|
|
||||||
await editorWindow.reload();
|
await editorWindow.reload();
|
||||||
await editorWindow.waitForLoadState("domcontentloaded");
|
await editorWindow.waitForLoadState("domcontentloaded");
|
||||||
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({
|
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 5. Select GIF as the export format.
|
await editorWindow.getByTestId("testId-export-panel-button").click();
|
||||||
await editorWindow.getByTestId("testId-gif-format-button").click();
|
await editorWindow.getByTestId(`testId-${format}-format-button`).click();
|
||||||
await editorWindow.getByTestId("testId-export-button").click();
|
await editorWindow.getByTestId("testId-export-button").click();
|
||||||
|
|
||||||
// ── 6. Wait for the success toast.
|
await expect
|
||||||
await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({
|
.poll(
|
||||||
timeout: 90_000,
|
() =>
|
||||||
});
|
app.evaluate(() => Boolean((globalThis as Record<string, unknown>)["__testExportData"])),
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
// ── 7. Write the captured buffer from the main-process global to disk.
|
|
||||||
const base64 = await app.evaluate(
|
const base64 = await app.evaluate(
|
||||||
() => (globalThis as Record<string, unknown>)["__testExportData"] as string,
|
() => (globalThis as Record<string, unknown>)["__testExportData"] as string,
|
||||||
);
|
);
|
||||||
fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
|
fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
|
||||||
|
|
||||||
// ── 8. Verify the file on disk is a valid GIF.
|
expect(fs.existsSync(outputPath), `${format.toUpperCase()} not found at ${outputPath}`).toBe(
|
||||||
expect(fs.existsSync(outputPath), `GIF not found at ${outputPath}`).toBe(true);
|
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);
|
const stats = fs.statSync(outputPath);
|
||||||
expect(stats.size).toBeGreaterThan(1024); // at least 1 KB
|
expect(stats.size).toBeGreaterThan(1024);
|
||||||
|
return fs.readFileSync(outputPath);
|
||||||
} finally {
|
} 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)) {
|
if (fs.existsSync(outputPath)) {
|
||||||
fs.unlinkSync(outputPath);
|
fs.unlinkSync(outputPath);
|
||||||
}
|
}
|
||||||
@@ -134,4 +143,16 @@ test("exports a GIF from a loaded video", async () => {
|
|||||||
fs.unlinkSync(testVideoInRecordings);
|
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/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user