Skip to content
Closed
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,23 @@ 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**:

```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()],
});
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).

## 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
60 changes: 60 additions & 0 deletions spec/auth-demo-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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>
`;

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", "*");
});
</script>
</main>
`,
contentType: "text/html",
});
});
};
48 changes: 48 additions & 0 deletions spec/popup-video.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { stat } from "node:fs/promises";
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("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] });
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);
}
});
88 changes: 88 additions & 0 deletions spec/popup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { test, expect } from "@playwright/test";
import { addPlugins, videoMode } from "../src/index.ts";
import { routeAuthDemoApp } from "./auth-demo-app.ts";

test("a popup wrapped with addPlugins runs actions through its own plugins", 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();
const 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();

// The popup's click went through the popup's own video-mode middleware...
await expect(popupVideo.metadata()).resolves.toMatchObject({
highlights: [{ method: "click" }],
});
// ...and the main page's timeline has only the main page's actions.
await expect(video.metadata()).resolves.toMatchObject({
highlights: [{ method: "click" }, { method: "waitFor" }],
});
});

test("each videoMode instance still owns its artifacts after the test", 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] });
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();
}

// Both pages have finalized. Each instance must write its own artifacts and
// read back its own timeline — not whichever page finalized last.
expect(popupVideo.outputPaths().metadata).not.toBe(video.outputPaths().metadata);
await expect(video.metadata()).resolves.toMatchObject({
highlights: [{ method: "click" }, { method: "waitFor" }],
});
await expect(popupVideo.metadata()).resolves.toMatchObject({
highlights: [{ method: "click" }],
});
});

test("reusing one videoMode instance on a popup fails with a clear error", 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();

// Wiring the same instance to a second page would wipe the main page's
// timeline, so it must fail loudly instead.
await expect(
addPlugins({ page: await popupPromise, testInfo, plugins: [video] }),
).rejects.toThrow("create a fresh videoMode() instance for each page");
});
Loading
Loading