diff --git a/apps/chrome-extension/e2e/browser.ts b/apps/chrome-extension/e2e/browser.ts
new file mode 100644
index 00000000000..5308ac41509
--- /dev/null
+++ b/apps/chrome-extension/e2e/browser.ts
@@ -0,0 +1,108 @@
+import { createHash } from "node:crypto";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { chromium } from "@playwright/test";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export const extensionPath = path.resolve(__dirname, "../dist");
+
+// Chrome derives an unpacked extension's id from its absolute path: on
+// Windows from the UTF-16 path with an upper-case drive letter.
+export const unpackedExtensionId = createHash("sha256")
+ .update(
+ process.platform === "win32"
+ ? Buffer.from(
+ extensionPath.replace(/^[a-z]:/, (drive) => drive.toUpperCase()),
+ "utf16le",
+ )
+ : extensionPath,
+ )
+ .digest("hex")
+ .slice(0, 32)
+ .replace(/[0-9a-f]/g, (digit) =>
+ String.fromCharCode(97 + Number.parseInt(digit, 16)),
+ );
+
+// CAP_E2E_BROWSER points the suites at an installed browser (Chrome, Edge,
+// Brave) instead of Playwright's Chromium. Branded Chrome ignores
+// --load-extension, so installed browsers load the extension over CDP.
+const installedBrowser = process.env.CAP_E2E_BROWSER;
+
+// Allows the extension's camera and mic in a new profile, as if the user had
+// accepted the permission prompt. Chrome refuses CDP permission grants for
+// extension origins, so the setting is written before the browser starts.
+const allowExtensionMediaInProfile = async (userDataDir: string) => {
+ const allow = { setting: 1 };
+ const exceptions = {
+ [`chrome-extension://${unpackedExtensionId}/,*`]: allow,
+ [`chrome-extension://${unpackedExtensionId},*`]: allow,
+ };
+ await mkdir(path.join(userDataDir, "Default"), { recursive: true });
+ await writeFile(
+ path.join(userDataDir, "Default", "Preferences"),
+ JSON.stringify({
+ profile: {
+ content_settings: {
+ exceptions: {
+ media_stream_camera: exceptions,
+ media_stream_mic: exceptions,
+ },
+ },
+ },
+ }),
+ );
+};
+
+export const launchExtensionBrowser = async ({
+ profilePrefix,
+ headless,
+ args,
+ allowExtensionMedia = false,
+}: {
+ profilePrefix: string;
+ headless: boolean;
+ args: string[];
+ allowExtensionMedia?: boolean;
+}) => {
+ const userDataDir = await mkdtemp(path.join(tmpdir(), profilePrefix));
+ if (allowExtensionMedia) await allowExtensionMediaInProfile(userDataDir);
+ const context = await chromium.launchPersistentContext(
+ userDataDir,
+ installedBrowser
+ ? {
+ executablePath: installedBrowser,
+ headless,
+ ignoreDefaultArgs: ["--disable-extensions"],
+ args: ["--enable-unsafe-extension-debugging", ...args],
+ }
+ : {
+ channel: "chromium",
+ headless,
+ args: [
+ `--disable-extensions-except=${extensionPath}`,
+ `--load-extension=${extensionPath}`,
+ ...args,
+ ],
+ },
+ );
+ if (installedBrowser) {
+ const browser = context.browser();
+ if (!browser) throw new Error("Installed browser did not expose CDP");
+ const session = await browser.newBrowserCDPSession();
+ await session.send(
+ "Extensions.loadUnpacked" as never,
+ {
+ path: extensionPath,
+ } as never,
+ );
+ await session.detach();
+ }
+ const cleanup = async () => {
+ await context.close();
+ await rm(userDataDir, { recursive: true, force: true });
+ };
+ return { context, cleanup };
+};
diff --git a/apps/chrome-extension/e2e/overlay-ui.spec.ts b/apps/chrome-extension/e2e/overlay-ui.spec.ts
index 307609c637a..24dd119ff78 100644
--- a/apps/chrome-extension/e2e/overlay-ui.spec.ts
+++ b/apps/chrome-extension/e2e/overlay-ui.spec.ts
@@ -1,20 +1,16 @@
-import { mkdtemp, rm } from "node:fs/promises";
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
import {
type BrowserContext,
type CDPSession,
- chromium,
expect,
type Page,
test,
} from "@playwright/test";
+import { launchExtensionBrowser } from "./browser";
type ChromeGlobal = typeof globalThis & {
chrome: {
@@ -56,8 +52,6 @@ type ChromeGlobal = typeof globalThis & {
};
};
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const extensionPath = path.resolve(__dirname, "../dist");
const SETTINGS_KEY = "cap-extension-settings";
const AUTH_KEY = "cap-extension-auth";
const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";
@@ -103,8 +97,19 @@ const createMockCapServer = async (
sendJson(response, 204, {});
return;
}
- if (request.method === "GET" && url.pathname === "/capture.html") {
- response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
+ if (
+ request.method === "GET" &&
+ (url.pathname === "/capture.html" || url.pathname === "/strict.html")
+ ) {
+ response.writeHead(200, {
+ "Content-Type": "text/html; charset=utf-8",
+ ...(url.pathname === "/strict.html"
+ ? {
+ "Content-Security-Policy":
+ "default-src 'none'; frame-src 'none'; img-src 'none'; style-src 'none'; script-src 'none'",
+ }
+ : {}),
+ });
response.end(
`
Cap Close UI TargetClose UI page
`,
);
@@ -199,13 +204,10 @@ const createMockCapServer = async (
};
const launchExtensionContext = async () => {
- const userDataDir = await mkdtemp(path.join(tmpdir(), "cap-close-e2e-"));
- const context = await chromium.launchPersistentContext(userDataDir, {
- channel: "chromium",
+ return launchExtensionBrowser({
+ profilePrefix: "cap-close-e2e-",
headless: true,
args: [
- `--disable-extensions-except=${extensionPath}`,
- `--load-extension=${extensionPath}`,
"--allow-http-screen-capture",
"--auto-select-tab-capture-source-by-title=Cap Close UI Target",
"--auto-select-desktop-capture-source=Cap Close UI Target",
@@ -215,11 +217,6 @@ const launchExtensionContext = async () => {
"--use-fake-ui-for-media-stream",
],
});
- const cleanup = async () => {
- await context.close();
- await rm(userDataDir, { recursive: true, force: true });
- };
- return { context, cleanup };
};
const getServiceWorker = async (context: BrowserContext) => {
@@ -235,7 +232,11 @@ const getServiceWorker = async (context: BrowserContext) => {
const configureExtension = async (
worker: Awaited>,
apiBaseUrl: string,
- options: { countdownEnabled?: boolean; webcamEnabled?: boolean } = {},
+ options: {
+ countdownEnabled?: boolean;
+ webcamEnabled?: boolean;
+ microphoneEnabled?: boolean;
+ } = {},
) => {
await worker.evaluate(
async ({ authKey, bootstrapKey, settingsKey, apiBaseUrl, options }) => {
@@ -271,7 +272,10 @@ const configureExtension = async (
shape: "round",
mirror: false,
},
- microphone: { enabled: false, deviceId: null },
+ microphone: {
+ enabled: options.microphoneEnabled ?? false,
+ deviceId: null,
+ },
systemAudio: { enabled: false },
sounds: { enabled: false },
countdown: {
@@ -601,14 +605,17 @@ test("an abandoned recording start clears without leaving controls behind", asyn
.toBe(true);
const devtools = await extension.context.newCDPSession(targetPage);
+ // A start is abandoned when the worker dies mid-start: session storage
+ // keeps "creating" and the next worker life has to clear it.
await writeStaleCreatingState(worker);
- await expect.poll(() => readSessionRecordingPhase(worker)).toBe("creating");
+ await devtools.send("ServiceWorker.enable");
+ await devtools.send("ServiceWorker.stopAllWorkers");
await targetPage.waitForTimeout(500);
expect(
await getClosedShadowElementBox(
devtools,
"class",
- "cap-extension-recording-rail",
+ "cap-extension-recording-badge",
),
).toBeNull();
@@ -621,7 +628,10 @@ test("an abandoned recording start clears without leaving controls behind", asyn
type: "get-recording-status",
})) as { status?: { phase?: string } };
expect(statusResponse.status?.phase).toBe("idle");
- await expect.poll(() => readSessionRecordingPhase(worker)).toBe("idle");
+ const restartedWorker = await getServiceWorker(extension.context);
+ await expect
+ .poll(() => readSessionRecordingPhase(restartedWorker))
+ .toBe("idle");
await expect
.poll(() => frameWithUrl(targetPage, "popup.html") === null, {
timeout: 10_000,
@@ -631,7 +641,7 @@ test("an abandoned recording start clears without leaving controls behind", asyn
await getClosedShadowElementBox(
devtools,
"class",
- "cap-extension-recording-rail",
+ "cap-extension-recording-badge",
),
).toBeNull();
} finally {
@@ -888,92 +898,77 @@ test("recording controls stay stable and the camera resizes directly", async ()
.toBe(true);
const devtools = await extension.context.newCDPSession(targetPage);
+ const getBadgeBox = () =>
+ getClosedShadowElementBox(
+ devtools,
+ "class",
+ "cap-extension-recording-badge",
+ );
+ const getDockBox = () =>
+ getClosedShadowElementBox(
+ devtools,
+ "class",
+ "cap-extension-vertical-dock",
+ );
+ const readPhase = async () => {
+ const response = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "get-recording-status",
+ })) as { status?: { phase?: string } };
+ return response.status?.phase;
+ };
await expect
- .poll(
- async () =>
- (await getClosedShadowElementBox(
- devtools,
- "class",
- "cap-extension-recording-rail",
- )) !== null,
- { timeout: 10_000 },
- )
+ .poll(async () => (await getBadgeBox()) !== null, { timeout: 10_000 })
.toBe(true);
- const initialBox = await getClosedShadowElementBox(
- devtools,
- "class",
- "cap-extension-recording-rail",
- );
- if (!initialBox) throw new Error("recording bar missing");
- expect(initialBox.x).toBeLessThanOrEqual(20);
- expect(initialBox.width).toBeGreaterThan(100);
- expect(initialBox.width).toBeLessThanOrEqual(128);
- expect(initialBox.height).toBeLessThanOrEqual(44);
- const handleBox = await getClosedShadowElementBox(
- devtools,
- "data-drag-handle",
- );
- if (!handleBox) throw new Error("recording bar drag handle missing");
+ const initialBox = await getBadgeBox();
+ if (!initialBox) throw new Error("recording badge missing");
+ expect(initialBox.x).toBeLessThanOrEqual(32);
+ expect(initialBox.height).toBeLessThanOrEqual(64);
const timeBox = await getClosedShadowElementBox(
devtools,
- "data-recording-time",
+ "class",
+ "cap-extension-recording-badge-time",
);
- if (!timeBox || timeBox.width < 30) {
+ if (!timeBox || timeBox.width < 20) {
throw new Error("recording time is not visible at rest");
}
- expect(
- await getClosedShadowComputedStyle(
- devtools,
- "data-recording-actions",
- "opacity",
- ),
- ).toBe("0");
+ expect(await getDockBox()).toBeNull();
await targetPage.mouse.move(
- handleBox.x + handleBox.width / 2,
- handleBox.y + handleBox.height / 2,
+ initialBox.x + initialBox.width / 2,
+ initialBox.y + initialBox.height / 2,
);
+ await expect.poll(async () => (await getDockBox()) !== null).toBe(true);
+ const settledPause: { box: ElementBox | null } = { box: null };
await expect
- .poll(() =>
- getClosedShadowComputedStyle(
+ .poll(async () => {
+ const previous = settledPause.box;
+ const next = await getClosedShadowElementBox(
devtools,
- "data-recording-actions",
- "opacity",
- ),
- )
- .toBe("1");
- const actionsBox = await getClosedShadowElementBox(
- devtools,
- "data-recording-actions",
- );
- if (!actionsBox) throw new Error("recording action capsule missing");
- const actionGap = actionsBox.x - (initialBox.x + initialBox.width);
- expect(actionGap).toBeGreaterThanOrEqual(4);
- expect(actionGap).toBeLessThanOrEqual(8);
- expect(
- await getClosedShadowComputedStyle(
- devtools,
- "data-recording-actions",
- "border-top-left-radius",
- ),
- ).toBe("14px");
- const pauseBox = await getClosedShadowElementBox(
- devtools,
- "data-recording-pause",
- );
+ "class",
+ "is-pause",
+ );
+ settledPause.box = next;
+ return (
+ previous !== null &&
+ next !== null &&
+ previous.x === next.x &&
+ previous.y === next.y
+ );
+ })
+ .toBe(true);
+ const pauseBox = settledPause.box;
if (!pauseBox) throw new Error("pause action missing on hover");
+ expect(
+ await getClosedShadowElementBox(devtools, "class", "is-mic"),
+ ).toBeNull();
await targetPage.mouse.move(
pauseBox.x + pauseBox.width / 2,
pauseBox.y + pauseBox.height / 2,
+ { steps: 4 },
);
await targetPage.waitForTimeout(300);
- expect(
- await getClosedShadowComputedStyle(
- devtools,
- "data-recording-actions",
- "opacity",
- ),
- ).toBe("1");
+ expect(await getDockBox()).not.toBeNull();
await targetPage.screenshot({
path: "test-results/recording-bar-expanded.png",
});
@@ -981,57 +976,41 @@ test("recording controls stay stable and the camera resizes directly", async ()
pauseBox.x + pauseBox.width / 2,
pauseBox.y + pauseBox.height / 2,
);
- await expect
- .poll(async () => {
- const response = (await sendServiceWorkerMessage(messengerPage, {
- target: "service-worker",
- type: "get-recording-status",
- })) as { status?: { phase?: string } };
- return response.status?.phase;
- })
- .toBe("paused");
- expect(
- await getClosedShadowComputedStyle(
- devtools,
- "data-recording-actions",
- "opacity",
- ),
- ).toBe("1");
+ await expect.poll(readPhase).toBe("paused");
+ expect(await getDockBox()).not.toBeNull();
+ await targetPage.waitForTimeout(500);
+ const resumeBox = await getClosedShadowElementBox(
+ devtools,
+ "class",
+ "is-paused",
+ );
+ if (!resumeBox) throw new Error("resume action missing while paused");
await targetPage.mouse.click(
- pauseBox.x + pauseBox.width / 2,
- pauseBox.y + pauseBox.height / 2,
+ resumeBox.x + resumeBox.width / 2,
+ resumeBox.y + resumeBox.height / 2,
);
- await expect
- .poll(async () => {
- const response = (await sendServiceWorkerMessage(messengerPage, {
- target: "service-worker",
- type: "get-recording-status",
- })) as { status?: { phase?: string } };
- return response.status?.phase;
- })
- .toBe("recording");
+ await expect.poll(readPhase).toBe("recording");
+
+ await targetPage.mouse.move(760, 40);
+ await expect.poll(async () => (await getDockBox()) === null).toBe(true);
await targetPage.mouse.move(
- handleBox.x + handleBox.width / 2,
- handleBox.y + handleBox.height / 2,
+ initialBox.x + initialBox.width / 2,
+ initialBox.y + initialBox.height / 2,
);
await targetPage.mouse.down();
await targetPage.mouse.move(
- handleBox.x + handleBox.width / 2 + 120,
- handleBox.y + handleBox.height / 2 - 80,
+ initialBox.x + initialBox.width / 2 + 420,
+ initialBox.y + initialBox.height / 2 - 80,
{ steps: 8 },
);
await targetPage.mouse.up();
await expect
.poll(async () => {
- const movedBox = await getClosedShadowElementBox(
- devtools,
- "class",
- "cap-extension-recording-rail",
- );
+ const movedBox = await getBadgeBox();
return (
movedBox !== null &&
- movedBox.x - initialBox.x > 80 &&
+ movedBox.x - initialBox.x > 380 &&
initialBox.y - movedBox.y > 50
);
})
@@ -1063,17 +1042,10 @@ test("recording controls stay stable and the camera resizes directly", async ()
),
)
.toBe(true);
+ expect(await readPhase()).toBe("recording");
await targetPage.mouse.move(760, 40);
- await expect
- .poll(() =>
- getClosedShadowComputedStyle(
- devtools,
- "data-recording-actions",
- "opacity",
- ),
- )
- .toBe("0");
+ await expect.poll(async () => (await getDockBox()) === null).toBe(true);
await targetPage.waitForTimeout(500);
await targetPage.screenshot({
path: "test-results/recording-bar-visible.png",
@@ -1149,3 +1121,300 @@ test("recording controls stay stable and the camera resizes directly", async ()
await mockServer.close();
}
});
+
+test("the dock mutes the recording microphone without changing saved settings", async () => {
+ test.setTimeout(90_000);
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin, {
+ webcamEnabled: false,
+ microphoneEnabled: true,
+ });
+
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+ const targetPage = await extension.context.newPage();
+ await targetPage.goto(`${mockServer.origin}/capture.html`);
+ await targetPage.bringToFront();
+
+ const startResponse = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "start-recording",
+ mode: "fullscreen",
+ })) as { ok: boolean };
+ expect(startResponse.ok).toBe(true);
+
+ const devtools = await extension.context.newCDPSession(targetPage);
+ const readMicrophone = async () => {
+ const response = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "get-recording-status",
+ })) as { status?: { microphone?: string } };
+ return response.status?.microphone;
+ };
+ const readSavedMicrophoneEnabled = () =>
+ worker.evaluate(
+ (settingsKey) =>
+ new Promise((resolve) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ chromeApi.storage.local.get(settingsKey, (items) => {
+ const settings = items[settingsKey] as
+ | { microphone?: { enabled?: boolean } }
+ | undefined;
+ resolve(settings?.microphone?.enabled);
+ });
+ }),
+ SETTINGS_KEY,
+ );
+ const settledMicBox = async () => {
+ const holder: { box: ElementBox | null } = { box: null };
+ await expect
+ .poll(async () => {
+ const previous = holder.box;
+ const next = await getClosedShadowElementBox(
+ devtools,
+ "class",
+ "is-mic",
+ );
+ holder.box = next;
+ return (
+ previous !== null &&
+ next !== null &&
+ previous.x === next.x &&
+ previous.y === next.y
+ );
+ })
+ .toBe(true);
+ if (!holder.box) throw new Error("microphone action missing on hover");
+ return holder.box;
+ };
+ const clickMic = async () => {
+ const badge = await getClosedShadowElementBox(
+ devtools,
+ "class",
+ "cap-extension-recording-badge",
+ );
+ if (!badge) throw new Error("recording badge missing");
+ await targetPage.mouse.move(
+ badge.x + badge.width / 2,
+ badge.y + badge.height / 2,
+ );
+ const micBox = await settledMicBox();
+ await targetPage.mouse.move(
+ micBox.x + micBox.width / 2,
+ micBox.y + micBox.height / 2,
+ { steps: 4 },
+ );
+ await targetPage.mouse.click(
+ micBox.x + micBox.width / 2,
+ micBox.y + micBox.height / 2,
+ );
+ };
+
+ await expect.poll(readMicrophone, { timeout: 15_000 }).toBe("live");
+ await expect
+ .poll(
+ async () =>
+ (await getClosedShadowElementBox(
+ devtools,
+ "class",
+ "cap-extension-recording-badge",
+ )) !== null,
+ { timeout: 10_000 },
+ )
+ .toBe(true);
+
+ await clickMic();
+ await expect.poll(readMicrophone).toBe("muted");
+ await expect
+ .poll(
+ async () =>
+ (await getClosedShadowElementBox(devtools, "class", "is-muted")) !==
+ null,
+ )
+ .toBe(true);
+ expect(await readSavedMicrophoneEnabled()).toBe(true);
+
+ await clickMic();
+ await expect.poll(readMicrophone).toBe("live");
+ expect(await readSavedMicrophoneEnabled()).toBe(true);
+
+ const stopResponse = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "stop-recording",
+ })) as { ok: boolean };
+ expect(stopResponse.ok).toBe(true);
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
+
+test("choosing Camera mode picks a camera so Start stays available", async () => {
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin, {
+ webcamEnabled: false,
+ });
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+ const targetPage = await extension.context.newPage();
+ await targetPage.goto(`${mockServer.origin}/capture.html`);
+ await targetPage.bringToFront();
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "open-recorder-panel",
+ });
+ await expect
+ .poll(() => frameWithUrl(targetPage, "popup.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+ const panel = frameWithUrl(targetPage, "popup.html");
+ if (!panel) throw new Error("recorder panel missing");
+
+ await panel
+ .getByRole("combobox")
+ .filter({ hasText: /Full Screen/ })
+ .click();
+ await panel.getByRole("option", { name: "Camera only" }).click();
+
+ await expect(
+ panel.getByRole("button", { name: "Start recording" }),
+ ).toBeEnabled();
+ const savedCamera = await worker.evaluate(
+ (settingsKey) =>
+ new Promise((resolve) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ chromeApi.storage.local.get(settingsKey, (items) => {
+ const settings = items[settingsKey] as
+ | { webcam?: { deviceId?: string | null } }
+ | undefined;
+ resolve(settings?.webcam?.deviceId);
+ });
+ }),
+ SETTINGS_KEY,
+ );
+ expect(savedCamera).toBeTruthy();
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
+
+test("the recorder panel still loads after other tabs push its token out of the registry", async () => {
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin, {
+ webcamEnabled: false,
+ });
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+ const targetPage = await extension.context.newPage();
+ await targetPage.goto(`${mockServer.origin}/capture.html`);
+ await targetPage.bringToFront();
+ const togglePanel = () =>
+ sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "open-recorder-panel",
+ });
+ const startButton = () =>
+ frameWithUrl(targetPage, "popup.html")
+ ?.getByRole("button", { name: "Start recording" })
+ .isVisible()
+ .catch(() => false) ?? false;
+
+ await togglePanel();
+ await expect.poll(startButton, { timeout: 15_000 }).toBe(true);
+ await togglePanel();
+ await expect
+ .poll(() => frameWithUrl(targetPage, "popup.html") === null)
+ .toBe(true);
+
+ // Hundreds of newer registrations, as from many tabs loading the
+ // overlay, evict this page's tokens.
+ await worker.evaluate(
+ () =>
+ new Promise((resolve) => {
+ const now = Date.now();
+ const tokens = Object.fromEntries(
+ Array.from({ length: 300 }, (_, index) => [
+ `other-tab-${index}`,
+ now - 300 + index,
+ ]),
+ );
+ (globalThis as ChromeGlobal).chrome.storage.session.set(
+ { "cap-extension-overlay-tokens": tokens },
+ resolve,
+ );
+ }),
+ );
+
+ await togglePanel();
+ await expect.poll(startButton, { timeout: 15_000 }).toBe(true);
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
+
+test("the recorder panel and camera preview load on a page with a strict CSP", async () => {
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin);
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+ const targetPage = await extension.context.newPage();
+ await targetPage.goto(`${mockServer.origin}/strict.html`);
+ await targetPage.bringToFront();
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "open-recorder-panel",
+ });
+ await expect
+ .poll(() => frameWithUrl(targetPage, "popup.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+ const panel = frameWithUrl(targetPage, "popup.html");
+ if (!panel) throw new Error("recorder panel missing");
+ await expect(
+ panel.getByRole("button", { name: "Start recording" }),
+ ).toBeVisible();
+ await expect
+ .poll(() => frameWithUrl(targetPage, "camera-preview.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+ const devtools = await extension.context.newCDPSession(targetPage);
+ const panelBox = await getClosedShadowElementBox(
+ devtools,
+ "class",
+ "cap-extension-panel",
+ );
+ expect(panelBox?.width ?? 0).toBeGreaterThan(200);
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
diff --git a/apps/chrome-extension/e2e/recording-upload.spec.ts b/apps/chrome-extension/e2e/recording-upload.spec.ts
index fcf73c51481..becf9c65c79 100644
--- a/apps/chrome-extension/e2e/recording-upload.spec.ts
+++ b/apps/chrome-extension/e2e/recording-upload.spec.ts
@@ -1,4 +1,5 @@
-import { mkdtemp, rm } from "node:fs/promises";
+import { execFileSync } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
import {
createServer,
type IncomingMessage,
@@ -6,15 +7,9 @@ import {
} from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
-import { fileURLToPath } from "node:url";
-import {
- type BrowserContext,
- chromium,
- expect,
- type Page,
- test,
-} from "@playwright/test";
+import { type BrowserContext, expect, type Page, test } from "@playwright/test";
import type { RecordingStatus } from "../src/shared/types";
+import { launchExtensionBrowser, unpackedExtensionId } from "./browser";
type ChromeRuntimeResponse =
| {
@@ -32,8 +27,10 @@ type MockState = {
initiateBodies: unknown[];
presignBodies: unknown[];
uploadBytes: number[];
+ uploadParts: Map;
uploadHeaders: Record[];
videoId: string;
+ bootstrapDelayMs: number;
};
type ChromeGlobal = typeof globalThis & {
@@ -54,8 +51,6 @@ type ChromeGlobal = typeof globalThis & {
};
};
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const extensionPath = path.resolve(__dirname, "../dist");
const SETTINGS_KEY = "cap-extension-settings";
const AUTH_KEY = "cap-extension-auth";
const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";
@@ -150,6 +145,35 @@ const animatedCapturePage = () => `