323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import { once } from "node:events";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { Page } from "@playwright/test";
|
|
import { _electron as electron, expect, test } from "@playwright/test";
|
|
import { NATIVE_BRIDGE_CHANNEL, NATIVE_BRIDGE_VERSION } from "../../src/native/contracts";
|
|
|
|
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");
|
|
|
|
async function launchApp() {
|
|
const testUserDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-e2e-"));
|
|
const app = await electron.launch({
|
|
args: [
|
|
MAIN_JS,
|
|
"--no-sandbox",
|
|
"--enable-unsafe-swiftshader",
|
|
"--lang=en-US",
|
|
`--user-data-dir=${testUserDataDir}`,
|
|
],
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_USER_DATA_DIR: testUserDataDir,
|
|
HEADLESS: process.env["HEADLESS"] ?? "true",
|
|
LANG: "en_US.UTF-8",
|
|
LC_ALL: "en_US.UTF-8",
|
|
LANGUAGE: "en_US",
|
|
},
|
|
});
|
|
|
|
const childProcess = app.process();
|
|
childProcess.stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`));
|
|
childProcess.stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));
|
|
(
|
|
app as ElectronApplication & {
|
|
__testUserDataDir?: string;
|
|
__childProcess?: ReturnType<ElectronApplication["process"]>;
|
|
}
|
|
).__testUserDataDir = testUserDataDir;
|
|
(
|
|
app as ElectronApplication & {
|
|
__testUserDataDir?: string;
|
|
__childProcess?: ReturnType<ElectronApplication["process"]>;
|
|
}
|
|
).__childProcess = childProcess;
|
|
|
|
return app;
|
|
}
|
|
|
|
async function closeApp(app: ElectronApplication) {
|
|
const childProcess = (
|
|
app as ElectronApplication & {
|
|
__childProcess?: ReturnType<ElectronApplication["process"]>;
|
|
}
|
|
).__childProcess;
|
|
await Promise.race([app.close(), new Promise<void>((resolve) => setTimeout(resolve, 5_000))]);
|
|
if (childProcess && childProcess.exitCode === null && childProcess.signalCode === null) {
|
|
if (!childProcess.killed) {
|
|
childProcess.kill();
|
|
}
|
|
await Promise.race([
|
|
once(childProcess, "close"),
|
|
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
]);
|
|
}
|
|
const testUserDataDir = (app as ElectronApplication & { __testUserDataDir?: string })
|
|
.__testUserDataDir;
|
|
if (testUserDataDir && fs.existsSync(testUserDataDir)) {
|
|
fs.rmSync(testUserDataDir, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 5,
|
|
retryDelay: 100,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function copyFixtureToRecordings(app: ElectronApplication, 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(TEST_VIDEO, targetPath);
|
|
return targetPath;
|
|
}
|
|
|
|
async function dismissLanguagePrompt(page: Page) {
|
|
const keepCurrentLanguage = page
|
|
.getByRole("button")
|
|
.filter({ hasText: /Keep current language|Conserver la langue actuelle/ });
|
|
if ((await keepCurrentLanguage.count()) > 0) {
|
|
await keepCurrentLanguage.click();
|
|
}
|
|
}
|
|
|
|
type ElectronApplication = Awaited<ReturnType<typeof electron.launch>>;
|
|
|
|
test.describe("Windows native checklist smoke tests", () => {
|
|
test.skip(process.platform !== "win32", "Windows native capture is Windows-only.");
|
|
|
|
test("source selector opens, lists thumbnails, and selects a screen/window source", async () => {
|
|
const app = await launchApp();
|
|
|
|
try {
|
|
const hudWindow = await app.firstWindow({ timeout: 60_000 });
|
|
await hudWindow.waitForLoadState("domcontentloaded");
|
|
await dismissLanguagePrompt(hudWindow);
|
|
|
|
await expect(hudWindow.getByTestId("launch-record-button")).toBeDisabled();
|
|
await expect(hudWindow.getByTestId("launch-source-selector-button")).toBeVisible();
|
|
await expect(hudWindow.getByTestId("launch-system-audio-button")).toBeEnabled();
|
|
await expect(hudWindow.getByTestId("launch-microphone-button")).toBeEnabled();
|
|
|
|
await hudWindow.getByTestId("launch-source-selector-button").click();
|
|
const sourceWindow = await app.waitForEvent("window", {
|
|
predicate: (w) => w.url().includes("windowType=source-selector"),
|
|
timeout: 15_000,
|
|
});
|
|
await sourceWindow.waitForLoadState("domcontentloaded");
|
|
|
|
const cards = sourceWindow.getByTestId("source-selector-card");
|
|
await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(0);
|
|
|
|
const thumbnails = await cards.locator("img").evaluateAll((imgs) =>
|
|
imgs.map((img) => ({
|
|
alt: img.getAttribute("alt"),
|
|
src: img.getAttribute("src"),
|
|
})),
|
|
);
|
|
expect(thumbnails.some((item) => item.alt && item.src?.startsWith("data:image"))).toBe(true);
|
|
|
|
const hasScreen = await sourceWindow
|
|
.locator('[data-testid="source-selector-card"][data-source-kind="screen"]')
|
|
.count()
|
|
.then((count) => count > 0);
|
|
const hasWindow = await sourceWindow
|
|
.locator('[data-testid="source-selector-card"][data-source-kind="window"]')
|
|
.count()
|
|
.then((count) => count > 0);
|
|
expect(hasScreen || hasWindow).toBe(true);
|
|
|
|
await expect(sourceWindow.getByTestId("source-selector-share-button")).toBeDisabled();
|
|
await cards.first().click();
|
|
await expect(sourceWindow.getByTestId("source-selector-share-button")).toBeEnabled();
|
|
await sourceWindow.getByTestId("source-selector-share-button").click();
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
hudWindow.evaluate(async () => {
|
|
return await window.electronAPI.getSelectedSource();
|
|
}),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.not.toBeNull();
|
|
await expect(hudWindow.getByTestId("launch-record-button")).toBeEnabled();
|
|
} finally {
|
|
await closeApp(app);
|
|
}
|
|
});
|
|
|
|
test("launch window opens an existing video into the editor and playback controls respond", async () => {
|
|
const app = await launchApp();
|
|
let testVideoInRecordings = "";
|
|
|
|
try {
|
|
const hudWindow = await app.firstWindow({ timeout: 60_000 });
|
|
await hudWindow.waitForLoadState("domcontentloaded");
|
|
await dismissLanguagePrompt(hudWindow);
|
|
testVideoInRecordings = await copyFixtureToRecordings(app, "checklist-sample.webm");
|
|
|
|
await app.evaluate(({ ipcMain }, videoPath) => {
|
|
ipcMain.removeHandler("open-video-file-picker");
|
|
ipcMain.handle("open-video-file-picker", () => ({
|
|
success: true,
|
|
path: videoPath,
|
|
}));
|
|
}, testVideoInRecordings);
|
|
|
|
await hudWindow.getByTestId("launch-open-video-button").click();
|
|
const editorWindow = await app.waitForEvent("window", {
|
|
predicate: (w) => w.url().includes("windowType=editor"),
|
|
timeout: 15_000,
|
|
});
|
|
await editorWindow.waitForLoadState("domcontentloaded");
|
|
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({ timeout: 20_000 });
|
|
|
|
const playButton = editorWindow.locator(
|
|
'button[aria-label="Play"], button[aria-label="Lire"]',
|
|
);
|
|
await expect(playButton).toBeVisible({ timeout: 10_000 });
|
|
await playButton.click();
|
|
|
|
const seekInput = editorWindow.locator('input[type="range"]').first();
|
|
await expect(seekInput).toBeVisible();
|
|
await seekInput.evaluate((input) => {
|
|
const range = input as HTMLInputElement;
|
|
range.value = "0.25";
|
|
range.dispatchEvent(new Event("input", { bubbles: true }));
|
|
range.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await expect.poll(() => seekInput.inputValue(), { timeout: 10_000 }).not.toBe("0");
|
|
|
|
await expect(
|
|
editorWindow.getByText("Background").or(editorWindow.getByText("Arrière-plan")),
|
|
).toBeVisible();
|
|
await expect(editorWindow.getByTestId("testId-export-button")).toBeVisible();
|
|
} finally {
|
|
await closeApp(app);
|
|
if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
|
|
fs.unlinkSync(testVideoInRecordings);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("launch window opens an existing project into the editor", async () => {
|
|
const app = await launchApp();
|
|
let testVideoInRecordings = "";
|
|
let projectPath = "";
|
|
|
|
try {
|
|
const hudWindow = await app.firstWindow({ timeout: 60_000 });
|
|
await hudWindow.waitForLoadState("domcontentloaded");
|
|
await dismissLanguagePrompt(hudWindow);
|
|
testVideoInRecordings = await copyFixtureToRecordings(app, "checklist-project-sample.webm");
|
|
projectPath = path.join(os.tmpdir(), `openscreen-checklist-${Date.now()}.openscreen`);
|
|
const project = {
|
|
version: 2,
|
|
videoPath: testVideoInRecordings,
|
|
editor: {},
|
|
};
|
|
fs.writeFileSync(projectPath, JSON.stringify(project), "utf-8");
|
|
|
|
await app.evaluate(
|
|
({ ipcMain }, payload) => {
|
|
ipcMain.removeHandler(payload.nativeBridgeChannel);
|
|
ipcMain.handle(payload.nativeBridgeChannel, (_event, request) => {
|
|
const success = (data: unknown) => ({
|
|
ok: true,
|
|
data,
|
|
meta: {
|
|
version: payload.nativeBridgeVersion,
|
|
requestId: request.requestId ?? "checklist-project-load",
|
|
timestampMs: Date.now(),
|
|
},
|
|
});
|
|
|
|
if (request.domain === "project" && request.action === "loadProjectFile") {
|
|
return success({
|
|
success: true,
|
|
path: payload.projectPath,
|
|
project: payload.project,
|
|
});
|
|
}
|
|
if (request.domain === "project" && request.action === "loadCurrentProjectFile") {
|
|
return success({ success: false, canceled: true });
|
|
}
|
|
if (request.domain === "project" && request.action === "getCurrentVideoPath") {
|
|
return success({ success: true, path: payload.videoPath });
|
|
}
|
|
if (request.domain === "system" && request.action === "getPlatform") {
|
|
return success("win32");
|
|
}
|
|
if (request.domain === "system" && request.action === "getAssetBasePath") {
|
|
return success(null);
|
|
}
|
|
if (request.domain === "cursor" && request.action === "getRecordingData") {
|
|
return success({ version: 2, provider: "none", samples: [], assets: [] });
|
|
}
|
|
if (request.domain === "cursor" && request.action === "getTelemetry") {
|
|
return success([]);
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
code: "UNSUPPORTED_ACTION",
|
|
message: `Unexpected native bridge request in test: ${request.domain}.${request.action}`,
|
|
retryable: false,
|
|
},
|
|
meta: {
|
|
version: payload.nativeBridgeVersion,
|
|
requestId: request.requestId ?? "checklist-project-load",
|
|
timestampMs: Date.now(),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
{
|
|
projectPath,
|
|
project,
|
|
videoPath: testVideoInRecordings,
|
|
nativeBridgeChannel: NATIVE_BRIDGE_CHANNEL,
|
|
nativeBridgeVersion: NATIVE_BRIDGE_VERSION,
|
|
},
|
|
);
|
|
|
|
await hudWindow.getByTestId("launch-open-project-button").click();
|
|
const editorWindow = await app.waitForEvent("window", {
|
|
predicate: (w) => w.url().includes("windowType=editor"),
|
|
timeout: 15_000,
|
|
});
|
|
await editorWindow.waitForLoadState("domcontentloaded");
|
|
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({ timeout: 20_000 });
|
|
await expect(editorWindow.getByTestId("testId-export-button")).toBeVisible();
|
|
} finally {
|
|
await closeApp(app);
|
|
if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
|
|
fs.unlinkSync(testVideoInRecordings);
|
|
}
|
|
if (projectPath && fs.existsSync(projectPath)) {
|
|
fs.unlinkSync(projectPath);
|
|
}
|
|
}
|
|
});
|
|
});
|