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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,10 @@ Recording begins at browser-context creation. By default, `videoMode` trims setu

```ts
const video = videoMode(); // trimStart: "auto" is the default
await using plugged = await addPlugins({ page, testInfo, plugins: [video] });
await using page = await addPlugins({ page: basePage, testInfo, plugins: [video] });

// The rendered video starts here, including this click's auto-wait.
await plugged.getByRole("button", { name: "Create report" }).click();
await page.getByRole("button", { name: "Create report" }).click();

// start when a known "ready" element first becomes visible (falls back to
// blank detection if it never appears):
Expand Down
8 changes: 4 additions & 4 deletions demo-video/src/snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ export const helperLines = (appearFrom: number, focusAt: number): CodeLine[] =>
{ text: `import { addPlugins, spinnerWaiter } from "middlewright";` },
{ text: `` },
{ text: `export const test = base.extend({` },
{ text: ` page: async ({ page }, use, testInfo) => {` },
{ text: ` await using plugged = await addPlugins({` },
{ text: ` page, testInfo, plugins: [spinnerWaiter()],`, kind: "focus" },
{ text: ` page: async ({ page: basePage }, use, testInfo) => {` },
{ text: ` await using page = await addPlugins({` },
{ text: ` page: basePage, testInfo, plugins: [spinnerWaiter()],`, kind: "focus" },
{ text: ` });` },
{ text: ` await use(plugged);` },
{ text: ` await use(page);` },
{ text: ` },` },
{ text: `});` },
];
Expand Down
34 changes: 17 additions & 17 deletions spec/debug-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
videoMode,
} from "../src/index.ts";

test("action middleware plugins are inert when PWDEBUG is set", async ({ page }, testInfo) => {
test("action middleware plugins are inert when PWDEBUG is set", async ({ page: basePage }, testInfo) => {
using _debug = withPwdebug();
let recoveryCalls = 0;

await using plugged = await addPlugins({
page,
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [
llmRecover({
Expand All @@ -29,15 +29,15 @@ test("action middleware plugins are inert when PWDEBUG is set", async ({ page },
spinnerWaiter({ spinnerTimeout: 3001 }),
],
});
await plugged.setContent(`
await page.setContent(`
<div data-hydrated="false">hydrating forever</div>
<div data-type="error">Exploded visibly</div>
<button disabled>Submit approval</button>
<div aria-label="Loading">Loading...</div>
`);

const start = Date.now();
const error = await plugged
const error = await page
.getByRole("button", { name: "Submit approval" })
.click({ timeout: 100 })
.catch((e: Error) => e);
Expand All @@ -50,32 +50,32 @@ test("action middleware plugins are inert when PWDEBUG is set", async ({ page },
expect(recoveryCalls).toBe(0);
});

test("videoMode controls are inert when PWDEBUG is set", async ({ page }, testInfo) => {
test("videoMode controls are inert when PWDEBUG is set", async ({ page: basePage }, testInfo) => {
using _debug = withPwdebug();

const plugged = await addPlugins({
page,
const page = await addPlugins({
page: basePage,
testInfo,
plugins: [videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } })],
});
await plugged.setContent(`<button>press</button>`);
await page.setContent(`<button>press</button>`);

plugged.videoMode.setStartTime();
await plugged.videoMode.deadAir(async () => {
await plugged.waitForTimeout(20);
page.videoMode.setStartTime();
await page.videoMode.deadAir(async () => {
await page.waitForTimeout(20);
});
await plugged.getByRole("button", { name: "press" }).click();
plugged.videoMode.setEndTime();
await page.getByRole("button", { name: "press" }).click();
page.videoMode.setEndTime();

await expect(plugged.videoMode.metadata()).resolves.toMatchObject({
await expect(page.videoMode.metadata()).resolves.toMatchObject({
deadAir: [],
highlights: [],
outputs: {},
sourceRange: {},
});
expect(plugged.videoMode.getVideoTimestamp()).toBe(0);
expect(page.videoMode.getVideoTimestamp()).toBe(0);

await plugged[Symbol.asyncDispose]();
await page[Symbol.asyncDispose]();
expect(existsSync(join(testInfo.outputDir, "video-mode.json"))).toBe(false);
});

Expand Down
36 changes: 18 additions & 18 deletions spec/hydration-waiter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import { test, expect } from "@playwright/test";
import { addPlugins, hydrationWaiter } from "../src/index.ts";

test("waits for hydration before clicking", async ({ page }, testInfo) => {
await using plugged = await addPlugins({
page,
test("waits for hydration before clicking", async ({ page: basePage }, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [hydrationWaiter()],
});
await plugged.setContent(getTestPageHtml(1500));
await page.setContent(getTestPageHtml(1500));

// The click handler only exists after "hydration" (1.5s in). Without the
// plugin, this click would land on a dead button.
await plugged.locator("#cta").click();
await plugged.locator("#result", { hasText: "done" }).waitFor();
await page.locator("#cta").click();
await page.locator("#result", { hasText: "done" }).waitFor();
});

test("without the plugin, clicking a dead button does nothing", async ({ page }, testInfo) => {
await using plugged = await addPlugins({
page,
test("without the plugin, clicking a dead button does nothing", async ({ page: basePage }, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [hydrationWaiter({ disabled: true })],
});
await plugged.setContent(getTestPageHtml(1500));
await page.setContent(getTestPageHtml(1500));

await plugged.locator("#cta").click();
const error = await plugged
await page.locator("#cta").click();
const error = await page
.locator("#result", { hasText: "done" })
.waitFor()
.catch((e: Error) => e);
Expand All @@ -33,16 +33,16 @@ test("without the plugin, clicking a dead button does nothing", async ({ page },
expect((error as Error).message).toMatch(/Timeout .* exceeded/);
});

test("custom selector", async ({ page }, testInfo) => {
await using plugged = await addPlugins({
page,
test("custom selector", async ({ page: basePage }, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [hydrationWaiter({ selector: ".app-loading" })],
});
await plugged.setContent(getTestPageHtml(1500, "app-loading"));
await page.setContent(getTestPageHtml(1500, "app-loading"));

await plugged.locator("#cta").click();
await plugged.locator("#result", { hasText: "done" }).waitFor();
await page.locator("#cta").click();
await page.locator("#result", { hasText: "done" }).waitFor();
});

function getTestPageHtml(hydrationDelayMs: number, markerClass?: string) {
Expand Down
62 changes: 31 additions & 31 deletions spec/llm-recover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,32 @@ import { addPlugins, llmRecover, type LlmRecoverOptions } from "../src/index.ts"
// --- Provider-injected tests (no API key needed, always run) ---

test("recovers using the injected provider and records a soft failure", async ({
page,
page: basePage,
}, testInfo) => {
const { plugged, assertions } = await plug(page, testInfo, {
const { page, assertions } = await plug(basePage, testInfo, {
requestRecoveryCode: async () => ({
code: `async function recover({ page }) { await page.getByText("Create your profile").click(); }`,
description: "stale copy: the button says 'Create your profile'",
}),
});
await plugged.setContent(getProfilePageHtml());
await page.setContent(getProfilePageHtml());

// Stale copy — the button actually says "Create your profile"
await plugged.getByText("Create profile").click();
await page.getByText("Create profile").click();

// The injected recovery code found and clicked the real button
await plugged.getByText("profile created").waitFor();
await page.getByText("profile created").waitFor();

expect(assertions).toHaveLength(1);
expect(assertions[0]).toMatch(/click failed and was recovered by LLM/);
expect(assertions[0]).toMatch(/Create your profile/);
});

test("retries with attempt history, then rethrows with a summary", async ({
page,
page: basePage,
}, testInfo) => {
const historySizes: number[] = [];
const { plugged } = await plug(page, testInfo, {
const { page } = await plug(basePage, testInfo, {
maxAttempts: 2,
requestRecoveryCode: async (_context, attemptHistory) => {
historySizes.push(attemptHistory.length);
Expand All @@ -41,9 +41,9 @@ test("retries with attempt history, then rethrows with a summary", async ({
};
},
});
await plugged.setContent(`<div>no buttons here</div>`);
await page.setContent(`<div>no buttons here</div>`);

const error = await plugged
const error = await page
.getByText("Create profile")
.click()
.catch((e: Error) => e);
Expand All @@ -62,16 +62,16 @@ test("retries with attempt history, then rethrows with a summary", async ({
expect(artifact).toMatchObject({ recovered: false, method: "click" });
});

test("provider returning no code rethrows the original error", async ({ page }, testInfo) => {
const { plugged, assertions } = await plug(page, testInfo, {
test("provider returning no code rethrows the original error", async ({ page: basePage }, testInfo) => {
const { page, assertions } = await plug(basePage, testInfo, {
requestRecoveryCode: async () => ({
code: null,
description: "Not recoverable: this page has no profile creation at all.",
}),
});
await plugged.setContent(`<div>no buttons here</div>`);
await page.setContent(`<div>no buttons here</div>`);

const error = await plugged
const error = await page
.getByText("Create profile")
.click()
.catch((e: Error) => e);
Expand All @@ -87,15 +87,15 @@ test("provider returning no code rethrows the original error", async ({ page },

const apiTest = process.env.LLM_RECOVER ? test : test.skip;

apiTest("recovers from out-of-date copy", async ({ page }, testInfo) => {
const { plugged, assertions } = await plug(page, testInfo, {});
await plugged.setContent(getProfilePageHtml());
apiTest("recovers from out-of-date copy", async ({ page: basePage }, testInfo) => {
const { page, assertions } = await plug(basePage, testInfo, {});
await page.setContent(getProfilePageHtml());

// The test uses stale copy — button actually says "Create your profile"
await plugged.getByText("Create profile").click();
await page.getByText("Create profile").click();

// Recovery should have found and clicked the real button
await plugged.getByText("profile created").waitFor();
await page.getByText("profile created").waitFor();

expect(assertions).toHaveLength(1);
expect(assertions[0]).toMatch(/click failed and was recovered by LLM/);
Expand All @@ -106,9 +106,9 @@ apiTest("recovers from out-of-date copy", async ({ page }, testInfo) => {
expect(flat).toMatch(/Recovery code: await page\.getBy\w+\(.*'Create your profile'.*\)\.click\(\)/);
});

apiTest("recovers from timing issue by waiting", async ({ page }, testInfo) => {
const { plugged, assertions } = await plug(page, testInfo, {});
await plugged.setContent(`
apiTest("recovers from timing issue by waiting", async ({ page: basePage }, testInfo) => {
const { page, assertions } = await plug(basePage, testInfo, {});
await page.setContent(`
<body>
<h1>Welcome</h1>
<p>You'll be able to create your profile in five seconds - hang tight</p>
Expand All @@ -127,33 +127,33 @@ apiTest("recovers from timing issue by waiting", async ({ page }, testInfo) => {
`);

// Button doesn't exist yet — appears after 5s
await plugged.getByText("Create profile").click();
await page.getByText("Create profile").click();

// Recovery should have waited and then clicked
await plugged.getByText("profile created").waitFor();
await page.getByText("profile created").waitFor();

expect(assertions).toHaveLength(1);
expect(assertions[0]).toMatch(/click failed and was recovered by LLM/);
});

apiTest("rethrows with context for genuine error", async ({ page }, testInfo) => {
const { plugged } = await plug(page, testInfo, {});
await plugged.setContent(`
apiTest("rethrows with context for genuine error", async ({ page: basePage }, testInfo) => {
const { page } = await plug(basePage, testInfo, {});
await page.setContent(`
<body>
<h1>Welcome</h1>
<p>Creating profile not allowed for preview users</p>
</body>
`);

await expect(async () => {
await plugged.getByText("Create profile").click();
await page.getByText("Create profile").click();
}).rejects.toThrow(/Not recoverable/i);
});

// --- helpers ---

/** Add the llm-recover plugin with a shimmed `expect.soft` that records instead of failing. */
async function plug(page: Page, testInfo: TestInfo, options: LlmRecoverOptions) {
async function plug(basePage: Page, testInfo: TestInfo, options: LlmRecoverOptions) {
const assertions: string[] = [];
const mockExpectSoft = (actual: unknown, message: string) => {
return {
Expand All @@ -167,12 +167,12 @@ async function plug(page: Page, testInfo: TestInfo, options: LlmRecoverOptions)
soft: mockExpectSoft,
}) as typeof expect;

const plugged = await addPlugins({
page,
const page = await addPlugins({
page: basePage,
testInfo,
plugins: [llmRecover({ expect: shimmedExpect, ...options })],
});
return { plugged, assertions };
return { page, assertions };
}

function getProfilePageHtml() {
Expand Down
Loading
Loading