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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A plugin/middleware system for Playwright locator actions — the one Playwright doesn't have.

Wrap `click`, `fill`, `waitFor` and friends with composable middleware, so your tests can be smart about *why* an action is slow or failing, without sprinkling `waitForSomething()` helpers through every test.
Wrap `click`, `fill`, `inputValue`, `waitFor` and friends with composable middleware, so your tests can be smart about *why* an action is slow or failing, without sprinkling `waitForSomething()` helpers through every test.

## Quick start

Expand Down Expand Up @@ -523,7 +523,7 @@ configured pattern must match the nearby comment:

## How it works

`addPlugins` patches `Locator.prototype` (once per process), replacing `click`, `dblclick`, `fill`, `type`, `press`, `clear`, `blur`, `focus`, `hover` and `waitFor` with a dispatcher. The dispatcher looks up the plugin state stored on the action's page; if the page has plugins, it runs the middleware chain (each middleware calling `next()` until the original method runs); if not, it calls the original method directly.
`addPlugins` patches `Locator.prototype` (once per process), replacing `click`, `dblclick`, `fill`, `type`, `press`, `clear`, `blur`, `focus`, `hover`, `inputValue` and `waitFor` with a dispatcher. The dispatcher looks up the plugin state stored on the action's page; if the page has plugins, it runs the middleware chain (each middleware calling `next()` until the original method runs); if not, it calls the original method directly.

To keep Playwright's HTML report pointing at *your test code* rather than plugin internals, it registers the plugin files with playwright-core's internal `setBoxedStackPrefixes` — the same mechanism Playwright uses to hide its own frames. This is the unofficial-API part of the hack; set `PLAYWRIGHT_PLUGIN_DEBUG=1` to disable it when debugging the plugins themselves.

Expand All @@ -546,3 +546,5 @@ Extracted from the internal test infrastructure of the [iterate](https://github.
## License

MIT

`inputValue()` uses the same loading-aware wait when the input has not appeared yet, then returns its current value. It does not wait for a particular value; an explicit timeout still passes through unchanged.
51 changes: 50 additions & 1 deletion spec/spinner-waiter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { test as base, expect } from "@playwright/test";
import { test as base, expect, selectors } from "@playwright/test";
import { addPlugins, defaultSelectors, spinnerWaiter, type Plugin } from "../src/index.ts";

const test = base.extend<{ slowMutationTimeout: number }>({
Expand Down Expand Up @@ -32,6 +32,28 @@ test("slow button succeeds when there's a spinner", async ({ page }) => {
await page.getByText("work done").waitFor();
});

test("inputValue waits for a loading composer and returns its value", async ({ page }) => {
await page.setContent(`
<p data-spinner="true">Opening chat…</p>
<script>
setTimeout(() => {
document.body.innerHTML = '<textarea aria-label="Message">About my edited note</textarea>';
}, 2000);
</script>
`);
expect(await page.getByLabel("Message").inputValue()).toBe("About my edited note");
});

test("inputValue preserves an explicit timeout and can read disabled inputs", async ({ page }) => {
await page.setContent('<input aria-label="Saved note" disabled value="green apples">');
expect(await page.getByLabel("Saved note").inputValue()).toBe("green apples");
await page.setContent('<p data-spinner="true">Opening chat…</p>');
// Explicit timeout must bypass spinner-waiter even while progress is visible.
await expect(page.getByLabel("Missing").inputValue({ timeout: 100 })).rejects.toThrow(
/Timeout 100ms exceeded/,
);
});

test("visible disabled button succeeds when there's a spinner", async ({ page }) => {
await page.setContent(`
<button
Expand Down Expand Up @@ -98,6 +120,33 @@ test("fails before a late spinner can make the no-spinner hint misleading", asyn
expect(elapsed).toBeLessThan(1500); // we don't tolerate the spinner taking a long time to appear
});

base(
"a control becoming ready during the loading check keeps its normal action budget",
async ({ page: basePage }, testInfo) => {
// Finish the UI update exactly when loading is checked, after the initial
// readiness check. The selector controls scheduling; the click is real.
await selectors.register("complete_spinner", () => ({
query: () => null,
queryAll(root: Document | Element) {
root.querySelector("button")?.removeAttribute("disabled");
root.querySelector("#spinner")?.remove();
return [];
},
}));
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [spinnerWaiter({ spinnerSelectors: ["complete_spinner=now"] })],
});
await page.setContent(`
<button disabled onclick="this.textContent = 'submitted'">Submit</button>
<p id="spinner">Loading…</p>
`);
await page.getByRole("button", { name: "Submit", exact: true }).click();
await page.getByRole("button", { name: "submitted", exact: true }).waitFor();
},
);

base("no-spinner fast fail still runs later middleware", async ({ page: basePage }, testInfo) => {
const calls: string[] = [];
const afterSpinner: Plugin = {
Expand Down
1 change: 1 addition & 0 deletions src/plugin-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const overrideableMethods = [
"blur",
"focus",
"hover",
"inputValue",
...oneArgMethods,
] satisfies (keyof Locator)[];
type OverrideableMethod = (typeof overrideableMethods)[number];
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/spinner-waiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ export const spinnerWaiter = Object.assign(
}

if (!loading) {
// The target may have appeared while we checked loading. Do not
// turn a now-ready action into a 1ms timeout just because its
// spinner disappeared between the two observations.
if (await locatorIsReady(locator, method)) return next();
// No spinner, no navigation - call action, suggest a spinner if it fails
settings.log(`${locator} not ready, nothing loading, failing fast`);
try {
Expand Down
Loading