Skip to content
Merged
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,28 @@ export default defineConfig({
});
```

### Popups

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();
const popup = await popupPromise; // already wrapped
await popup.getByRole("button", { name: "Approve" }).click();
```

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

**Writing your own plugins is the intended way to use this package.** The bundled five exist because they were useful for one particular app; your app has its own loading conventions, error surfaces, and flake patterns. Each bundled plugin is one small self-contained file — use them as inspiration: [spinner-waiter](./src/plugins/spinner-waiter.ts) (conditional waiting + error enrichment + runtime settings via `AsyncLocalStorage`), [hydration-waiter](./src/plugins/hydration-waiter.ts) (the simplest one — start here), [ui-error-reporter](./src/plugins/ui-error-reporter.ts) (catch/enrich/rethrow), [video-mode](./src/plugins/video-mode.ts) (video annotations/artifacts + lifecycle hooks), [llm-recover](./src/plugins/llm-recover.ts) (recovery loops, artifacts, soft assertions). The source also ships inside the npm package, so it's right there in `node_modules/middlewright/src`.
Expand Down
130 changes: 130 additions & 0 deletions spec/auth-demo-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { BrowserContext } from "@playwright/test";

/**
* app.middlewright.test shows a Sign in button that opens an auth popup on
* auth.middlewright.test; approving there posts a message back to the opener,
* which then shows who signed in. Routed on the context so the popup page is
* covered too.
*/
const demoStyle = `
<style>
body { font-family: system-ui, sans-serif; display: grid; place-items: center; min-height: 90vh; background: #f4f4f5; }
main { background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px 48px; text-align: center; }
h1 { font-size: 22px; margin: 0 0 16px; }
button { font-size: 16px; padding: 10px 24px; border-radius: 8px; border: none; background: #4f46e5; color: white; cursor: pointer; }
output { display: block; margin-top: 16px; font-size: 16px; color: #16a34a; }
</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({
body: `
${demoStyle}
<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}
<main>
<h1>Authorize middlewright?</h1>
<button id="approve">Approve</button>
<script>
document.querySelector("#approve").addEventListener("click", () => {
window.opener.postMessage("approved", "*");
window.close();
});
</script>
</main>
`,
contentType: "text/html",
});
});
};
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);
});
176 changes: 176 additions & 0 deletions spec/popup-video.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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,
}, testInfo) => {
await routeAuthDemoApp(context);
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], popups: false });
await page.goto("https://app.middlewright.test/");

const popupPromise = basePage.waitForEvent("popup");
await page.getByRole("button", { name: "Sign in" }).click();
popupVideo = videoMode({ finalHold: 0, highlight: { mode: "outline", duration: 300 }, trimStart: "never" });
await using popup = await addPlugins({
page: await popupPromise,
testInfo,
plugins: [popupVideo],
});

await popup.getByRole("button", { name: "Approve" }).click();
await page.getByText("Signed in as mmkal").waitFor();
}

// Playwright screencasts each page separately, so each instance ends the
// test with its own raw recording and its own annotated render.
await expect(video.metadata()).resolves.toMatchObject({
outputs: { raw: "video-raw.webm", rendered: "video-rendered.webm" },
});
await expect(popupVideo.metadata()).resolves.toMatchObject({
outputs: { raw: "video-raw-2.webm", rendered: "video-rendered-2.webm" },
});
for (const path of [
video.outputPaths().raw,
video.outputPaths().rendered,
popupVideo.outputPaths().raw,
popupVideo.outputPaths().rendered,
]) {
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