diff --git a/README.md b/README.md index f8bd6f3..f26d69d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. diff --git a/spec/spinner-waiter.spec.ts b/spec/spinner-waiter.spec.ts index e303413..6fcd229 100644 --- a/spec/spinner-waiter.spec.ts +++ b/spec/spinner-waiter.spec.ts @@ -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 }>({ @@ -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(` +

Opening chat…

+ + `); + 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(''); + expect(await page.getByLabel("Saved note").inputValue()).toBe("green apples"); + await page.setContent('

Opening chat…

'); + // 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(` +

Loading…

+ `); + 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 = { diff --git a/src/plugin-system.ts b/src/plugin-system.ts index 7788774..228dd1b 100644 --- a/src/plugin-system.ts +++ b/src/plugin-system.ts @@ -27,6 +27,7 @@ const overrideableMethods = [ "blur", "focus", "hover", + "inputValue", ...oneArgMethods, ] satisfies (keyof Locator)[]; type OverrideableMethod = (typeof overrideableMethods)[number]; diff --git a/src/plugins/spinner-waiter.ts b/src/plugins/spinner-waiter.ts index d724660..feffb88 100644 --- a/src/plugins/spinner-waiter.ts +++ b/src/plugins/spinner-waiter.ts @@ -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 {