Skip to content
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,20 +353,25 @@ export default defineConfig({

### Popups

Pages you never wrap (popups, `context.newPage()`) fall through to plain Playwright. To get plugin behavior in a popup — an OAuth window, say — wrap it with a second `addPlugins` call, using **fresh plugin instances**:
Popups are wrapped automatically. When a wrapped page opens one — an OAuth window, say — the popup gets the same plugin treatment, no wiring:

```ts
const popupPromise = page.waitForEvent("popup");
await page.getByRole("button", { name: "Sign in" }).click();
await using popup = await addPlugins({
page: await popupPromise,
testInfo,
plugins: [spinnerWaiter(), videoMode()],
});
const popup = await popupPromise; // already wrapped
await popup.getByRole("button", { name: "Approve" }).click();
```

Playwright screencasts each page separately, so the popup's `videoMode` produces its own video; its artifacts get a `-2` suffix (`video-rendered-2.webm`, `video-mode-2.json`, …) so they sit next to the main page's in the same output dir. Reusing the main page's `videoMode` instance on the popup would wipe the main timeline, so it throws instead — one instance per page. See [spec/popup.spec.ts](spec/popup.spec.ts).
In video mode, the popup renders as an overlay **in the main page's video**: scaled to fit 90% of the frame over the dimmed page, faded in and out on open/close, with popup clicks pointer-annotated inside the overlay. One composed video per test, popups included. The popup's facts land in `video-mode.json` under `children`.

Details and escape hatches:

- Plugins can control what a popup gets via the `forPopup(ctx)` hook — return a plugin for the popup, or `null` to skip. Hookless plugins are re-registered as-is (fine for stateless ones).
- `addPlugins({ ..., popups: false })` turns auto-wrap off. You can then wrap the popup manually with **fresh plugin instances** — a fresh `videoMode()` gives the popup its own standalone video, with `-2`-suffixed artifacts (`video-rendered-2.webm`, `video-mode-2.json`, …).
- Wrapping an already-wrapped page throws, as does reusing an active `videoMode` instance on a second page — one instance per page.
- Popup dialogs (`alert`/`confirm`/`prompt` opened by the popup) aren't annotated in video mode yet.

See [spec/popup.spec.ts](spec/popup.spec.ts) and [spec/popup-overlay-demo.spec.ts](spec/popup-overlay-demo.spec.ts).

## Writing your own plugin

Expand Down
70 changes: 70 additions & 0 deletions spec/auth-demo-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,75 @@ const demoStyle = `
</style>
`;

/**
* Demo-video variant of the auth flow: same app page, but the popup is a
* realistic sign-in form (inert username/password fields, a Sign in button
* that notifies the opener and closes) on a visibly different background so
* the popout reads clearly in the rendered overlay.
*/
export const routeSignInDemoApp = async (context: BrowserContext) => {
await routeAuthDemoApp(context);
// The app page gets a colored background so the dimmed page under the
// popup overlay reads clearly in the rendered video.
await context.route("https://app.middlewright.test/**", async (route) => {
await route.fulfill({
body: `
${demoStyle}
<style>
body { background: #0d9488; }
h1 { color: #134e4a; }
</style>
<main>
<h1>middlewright dashboard</h1>
<button id="signin">Sign in</button>
<output></output>
<script>
document.querySelector("#signin").addEventListener("click", () => {
window.open("https://auth.middlewright.test/authorize");
});
window.addEventListener("message", (event) => {
if (event.data === "approved") {
document.querySelector("output").textContent = "Signed in as mmkal";
}
});
</script>
</main>
`,
contentType: "text/html",
});
});
await context.route("https://auth.middlewright.test/**", async (route) => {
await route.fulfill({
body: `
${demoStyle}
<style>
body { background: #312e81; }
main { text-align: left; }
h1 { text-align: center; }
label { display: block; margin: 12px 0 4px; font-size: 14px; color: #52525b; }
input { display: block; width: 240px; font-size: 16px; padding: 8px 10px; border: 1px solid #d4d4d8; border-radius: 6px; }
button { margin-top: 20px; width: 100%; }
</style>
<main>
<h1>Sign in to middlewright</h1>
<label for="username">Username</label>
<input id="username" type="text" />
<label for="password">Password</label>
<input id="password" type="password" />
<button id="signin">Sign in</button>
<script>
document.querySelector("#signin").addEventListener("click", () => {
window.opener.postMessage("approved", "*");
window.close();
});
</script>
</main>
`,
contentType: "text/html",
});
});
};

export const routeAuthDemoApp = async (context: BrowserContext) => {
await context.route("https://app.middlewright.test/**", async (route) => {
await route.fulfill({
Expand Down Expand Up @@ -50,6 +119,7 @@ export const routeAuthDemoApp = async (context: BrowserContext) => {
<script>
document.querySelector("#approve").addEventListener("click", () => {
window.opener.postMessage("approved", "*");
window.close();
});
</script>
</main>
Expand Down
48 changes: 48 additions & 0 deletions spec/popup-overlay-demo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Demo-grade popup flow with the full watchable treatment — pointer
// highlights, step captions, address bar, popup overlay composite. The
// rendered output doubles as the PR/README demo video.
import { stat } from "node:fs/promises";
import { test, expect } from "@playwright/test";
import { addPlugins, videoMode } from "../src/index.ts";
import { routeSignInDemoApp } from "./auth-demo-app.ts";

test.use({ video: "on", viewport: { width: 960, height: 540 } });

test("auth popup demo", async ({ page: basePage, context }, testInfo) => {
await routeSignInDemoApp(context);
const video = videoMode();
{
await using page = await addPlugins({ page: basePage, testInfo, plugins: [video] });

const popupPromise = basePage.waitForEvent("popup");
await test.step("Open the sign-in popup", async () => {
await page.goto("https://app.middlewright.test/");
// Real frames on each side of the popup span keep the composite honest
// (and the demo watchable) — an instant flow would land before the
// screencast's first frame.
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Sign in" }).click();
});

const popup = await popupPromise;
await test.step("Sign in as mmkal", async () => {
await popup.waitForTimeout(500);
await popup.getByLabel("Username").fill("mmkal");
await popup.getByLabel("Password").fill("hunter2");
await popup.getByRole("button", { name: "Sign in" }).click();
});

await test.step("Back on the app, signed in", async () => {
await page.getByText("Signed in as mmkal").waitFor();
await page.waitForTimeout(500);
});
}

const metadata = await video.metadata();
expect(metadata).toMatchObject({
children: [{ closedAt: expect.any(Number), openedAt: expect.any(Number) }],
outputs: { raw: "video-raw.webm", rendered: "video-rendered.webm" },
});
expect(metadata.children[0].closedAt!).toBeGreaterThan(metadata.children[0].openedAt);
expect((await stat(video.outputPaths().rendered)).size).toBeGreaterThan(0);
});
130 changes: 129 additions & 1 deletion spec/popup-video.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,92 @@
import { execFile as execFileCallback } from "node:child_process";
import { stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { test, expect } from "@playwright/test";
import { addPlugins, videoMode } from "../src/index.ts";
import { routeAuthDemoApp } from "./auth-demo-app.ts";

test.use({ video: "on" });

test("captures an auto-wrapped popup's raw screencast for the composite", async ({
page: basePage,
context,
}, testInfo) => {
await routeAuthDemoApp(context);
const video = videoMode({ finalHold: 0, highlight: { mode: "outline", duration: 300 }, trimStart: "never" });
{
await using page = await addPlugins({ page: basePage, testInfo, plugins: [video] });
await page.goto("https://app.middlewright.test/");

const popupPromise = basePage.waitForEvent("popup");
await page.getByRole("button", { name: "Sign in" }).click();
await (await popupPromise).getByRole("button", { name: "Approve" }).click();
await page.getByText("Signed in as mmkal").waitFor();
}

const metadata = await video.metadata();
expect(metadata.children).toMatchObject([
{
// The demo popup closes itself after Approve, like a real OAuth popup —
// closedAt comes from the close event, and there is no settled
// recordingEndedAt (the screencast start approximates the timeline).
closedAt: expect.any(Number),
highlights: [{ method: "click" }],
openedAt: expect.any(Number),
raw: "video-raw-popup-1.webm",
viewport: { height: expect.any(Number), width: expect.any(Number) },
},
]);
const [child] = metadata.children;
expect(child.closedAt!).toBeGreaterThan(child.openedAt);
expect((await stat(join(testInfo.outputDir, child.raw!))).size).toBeGreaterThan(0);
});

test("renders the popup as a dimmed overlay in one composed video", async ({
page: basePage,
context,
}, testInfo) => {
await routeAuthDemoApp(context);
const video = videoMode({
addressBar: false,
finalHold: 0,
highlight: { mode: "outline", duration: 500 },
trimStart: "never",
});
{
await using page = await addPlugins({ page: basePage, testInfo, plugins: [video] });
await page.goto("https://app.middlewright.test/");
// Let the screencast capture real frames on each side of the popup span —
// an instant flow lands entirely before the recorder's first frame.
await page.waitForTimeout(500);

const popupPromise = basePage.waitForEvent("popup");
await page.getByRole("button", { name: "Sign in" }).click();
const popup = await popupPromise;
await popup.waitForTimeout(500);
await popup.getByRole("button", { name: "Approve" }).click();
await page.getByText("Signed in as mmkal").waitFor();
await page.waitForTimeout(500);
}

await expect(video.metadata()).resolves.toMatchObject({
outputs: { rendered: "video-rendered.webm" },
});
const frames = await videoFrameSamples(video.outputPaths().rendered);
// The demo app's background is a light gray (~245) throughout, so a
// darkened corner marks a frame where the popup backdrop dim is active.
// The downscale blends the thin dim border with its bright neighbors, so
// dimmed corners read ~211 (overlay up) down to ~147 (exit fade), against
// ~245 when lit.
const dimmedFrames = frames.filter((frame) => frame.corner < 235);
const litFrames = frames.filter((frame) => frame.corner >= 235);
expect(dimmedFrames.length).toBeGreaterThan(0);
expect(litFrames.length).toBeGreaterThan(0);
// While dimmed, the popup's white card sits centered above the backdrop.
const overlayFrames = dimmedFrames.filter((frame) => frame.centerPeak > 220);
expect(overlayFrames.length).toBeGreaterThan(0);
});

test("records separate videos for the main page and an auth popup", async ({
page: basePage,
context,
Expand All @@ -13,7 +95,7 @@ test("records separate videos for the main page and an auth popup", async ({
const video = videoMode({ finalHold: 0, highlight: { mode: "outline", duration: 300 }, trimStart: "never" });
let popupVideo!: ReturnType<typeof videoMode>;
{
await using page = await addPlugins({ page: basePage, testInfo, plugins: [video] });
await using page = await addPlugins({ page: basePage, testInfo, plugins: [video], popups: false });
await page.goto("https://app.middlewright.test/");

const popupPromise = basePage.waitForEvent("popup");
Expand Down Expand Up @@ -46,3 +128,49 @@ test("records separate videos for the main page and an auth popup", async ({
expect((await stat(path)).size).toBeGreaterThan(0);
}
});

const execFile = promisify(execFileCallback);

/**
* Decode the video to small grayscale frames and sample each one: a pixel
* near the bottom-left corner (page background), and the brightest pixel of
* the central quarter (the popup card when the overlay is up). 0-255.
*/
const videoFrameSamples = async (path: string) => {
const size = 64;
const { stdout } = await execFile(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-i",
path,
"-vf",
`fps=10,scale=${size}:${size},format=gray`,
"-f",
"rawvideo",
"-pix_fmt",
"gray",
"pipe:1",
],
{ encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
);
const frameSize = size * size;
const frames: { centerPeak: number; corner: number }[] = [];

for (let offset = 0; offset + frameSize <= stdout.length; offset += frameSize) {
let centerPeak = 0;
for (let y = Math.floor(size * 0.375); y < Math.floor(size * 0.625); y += 1) {
for (let x = Math.floor(size * 0.375); x < Math.floor(size * 0.625); x += 1) {
centerPeak = Math.max(centerPeak, stdout[offset + y * size + x]);
}
}
frames.push({
centerPeak,
corner: stdout[offset + (size - 4) * size + 3],
});
}

return frames;
};
Loading
Loading