From 9ed25ae6c97d29b80b2f9de3d96e07d1036a4e9f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:11:42 +0100 Subject: [PATCH 1/2] Add task: spinner-waiter treats in-flight navigation as loading Found via ../iterate mobile specs, which still need explicit timeouts on popup actions because the popup is mid-navigation to the auth worker - no document, so no app spinner, so spinner-waiter fast-fails. The browser itself is visibly loading; count it. Co-Authored-By: Claude Fable 5 --- tasks/spinner-waiter-navigation-loading.md | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tasks/spinner-waiter-navigation-loading.md diff --git a/tasks/spinner-waiter-navigation-loading.md b/tasks/spinner-waiter-navigation-loading.md new file mode 100644 index 0000000..0f3d0e2 --- /dev/null +++ b/tasks/spinner-waiter-navigation-loading.md @@ -0,0 +1,51 @@ +--- +status: in-progress +size: small +branch: spinner-waiter-navigation +--- + +# spinner-waiter: an in-flight navigation counts as loading + +**Status summary**: spec'd, implementing. Small change to `src/plugins/spinner-waiter.ts` plus a spec. + +## Why + +`../iterate`'s mobile specs still carry `{ timeout: 15_000 }` on popup actions +even though popups auto-wrap now. The comments say why: the popup is +*mid-navigation* to the auth worker ("the popup event fires before its +cross-server auth navigation mounts the login choices"; "clicks land after +auth-worker navigations that run cold on fresh preview deploys — CI-proven +>1s"). During a navigation the app cannot render a spinner — there is no +document yet — so spinner-waiter sees no loading UI and fast-fails in 1ms. +But the browser itself is visibly loading. That is loading UI by any honest +reading of the plugin's rule ("if the app is visibly loading, wait longer"). + +## Decision + +Treat an in-flight navigation exactly like a visible spinner: + +- `document.readyState === "loading"` (until DOMContentLoaded — deliberately + not `"interactive"`, so slow subresources can't mask a broken page), or +- no execution context to ask (`page.evaluate` throws "Execution context was + destroyed … navigation") — the gap between a navigation's commit and its new + document. + +A closed page is not loading. The same predicate feeds both the initial +"is anything loading?" check and the "loading finished without the target" +bail-out, so error hints stay accurate ("Loading finished (spinner gone / +navigation done)"). + +Out of scope: `page.waitForEvent("popup", { timeout })` — an event wait, not a +locator action; middlewright never sees it and the fix there is product +loading UI on the button that triggers the auth round-trips. + +## Checklist + +- [ ] `pageIsNavigating(page)` predicate; fold into the loading checks +- [ ] spec: a click whose target only exists after a slow cross-page navigation (no app spinner) succeeds without an explicit timeout +- [ ] spec: navigation completes but the target never appears → fast fail after the navigation, with the loading-finished hint +- [ ] README: mention navigation counts as loading under spinnerWaiter + +## Implementation log + +- 2026-08-18: found via `../iterate` specs (`specs/mobile/{notifications,approvals}.spec.ts`). From 04df653371388418380b611138cb896e957cfce9 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:22:35 +0100 Subject: [PATCH 2/2] spinner-waiter: a still-loading document counts as loading UI A document that hasn't fired load (readyState !== "complete"), or has no execution context yet while a navigation commits, is loading UI the app cannot draw itself - the browser's tab spinner is on. spinner-waiter now waits through it like an app spinner, bounded by the same grace period and spinnerTimeout, and the loading-finished bail-out considers it too. This covers the window after a cross-server hop commits and before the cold page finishes rendering client-side - why ../iterate's OAuth-popup specs still carried explicit timeouts on popup actions. Playwright's own locator queries already block until a pending navigation commits, so that earlier phase needs nothing from us (a first cut tracked pending requests; instrumenting showed it never fired, so it's gone). Known limitation, recorded in the task file: an action that initiates a slow navigation (clicking a link to a cold server) is bounded by its own actionTimeout - Playwright waits for the navigation inside the action, after spinner-waiter has let it through. Co-Authored-By: Claude Fable 5 --- README.md | 2 + spec/spinner-waiter.spec.ts | 67 +++++++++++++++++ src/plugins/spinner-waiter.ts | 71 ++++++++++++++----- ...08-18-spinner-waiter-navigation-loading.md | 66 +++++++++++++++++ tasks/spinner-waiter-navigation-loading.md | 51 ------------- 5 files changed, 187 insertions(+), 70 deletions(-) create mode 100644 tasks/complete/2026-08-18-spinner-waiter-navigation-loading.md delete mode 100644 tasks/spinner-waiter-navigation-loading.md diff --git a/README.md b/README.md index d1a42f4..98b8860 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ Fair enough — *inside* Playwright's watchdog architecture it may not be. But i The flagship. Before each action, if the target element isn't visible but a spinner is, waits (up to `spinnerTimeout`) for the element — bailing out early if the spinner disappears without producing it. If there's no spinner and the action fails, the error message suggests adding one: +A document that hasn't finished loading counts as a spinner too: right after a cross-server hop (an OAuth popup, say) the page may still be fetching and rendering before its `load` event — no app spinner exists yet, but the browser's tab spinner is on — so actions wait through it the same way. That covers auto-wrapped OAuth popups without explicit timeouts. + ``` Timeout 1000ms exceeded. If this is a slow operation, update the product code to add a spinner while it's running. diff --git a/spec/spinner-waiter.spec.ts b/spec/spinner-waiter.spec.ts index b55aba3..16f87f8 100644 --- a/spec/spinner-waiter.spec.ts +++ b/spec/spinner-waiter.spec.ts @@ -256,3 +256,70 @@ test("disappearance waits pass through untouched", async ({ page }) => { expect(String(error)).toContain("Timeout 1000ms exceeded"); expect(Date.now() - start).toBeGreaterThan(500); }); + +test("waits while a freshly navigated document is still loading, with no spinner", async ({ + page, +}) => { + // A cross-server hop lands on a page that renders its UI on `load`, and a + // slow subresource keeps `load` 2.5s away. The app draws no spinner — the + // browser's own tab spinner is the only loading UI. Playwright itself waits + // for the navigation to commit; spinner-waiter must keep waiting until the + // document finishes loading instead of fast-failing at commit. + await page.route("https://app.middlewright.test/**", async (route) => { + await route.fulfill({ body: `

middlewright dashboard

`, contentType: "text/html" }); + }); + await page.route("https://auth.middlewright.test/slow.png", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2500)); + await route.fulfill({ body: Buffer.alloc(0), contentType: "image/png" }); + }); + await page.route("https://auth.middlewright.test/consent", async (route) => { + await route.fulfill({ + body: ` + + + `, + contentType: "text/html", + }); + }); + await page.goto("https://app.middlewright.test/"); + // Kick the hop off without a Playwright action waiting on it, the way a + // popup arrives already navigating. + await page.evaluate(() => { + location.assign("https://auth.middlewright.test/consent"); + }); + + await page.getByRole("button", { name: "Allow access" }).click(); +}); + +test("fails fast once a navigation has fully loaded without the expected element", async ({ + page, +}) => { + await page.route("https://app.middlewright.test/**", async (route) => { + await route.fulfill({ body: `

middlewright dashboard

`, contentType: "text/html" }); + }); + await page.route("https://auth.middlewright.test/**", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + await route.fulfill({ body: `

Something went wrong

`, contentType: "text/html" }); + }); + await page.goto("https://app.middlewright.test/"); + await page.evaluate(() => { + location.assign("https://auth.middlewright.test/consent"); + }); + + const start = Date.now(); + const error = await page + .getByRole("button", { name: "Allow access" }) + .click() + .catch((e: Error) => e); + + // The navigation is over and the document is complete: nothing is loading, + // so this is the ordinary no-spinner fast-fail — not a long wait. + expect(error).toBeInstanceOf(Error); + expect(String(error)).toMatch(/Timeout 1ms exceeded/); + expect(String(error)).toMatch(/add a spinner/i); + expect(Date.now() - start).toBeLessThan(6_000); +}); diff --git a/src/plugins/spinner-waiter.ts b/src/plugins/spinner-waiter.ts index 7dc7d59..43a5558 100644 --- a/src/plugins/spinner-waiter.ts +++ b/src/plugins/spinner-waiter.ts @@ -7,7 +7,7 @@ * a different effective action timeout while the app is visibly loading. */ import { AsyncLocalStorage } from "node:async_hooks"; -import type { Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import type { ActionContext, LocatorWithOriginal, Plugin } from "../plugin-system.ts"; import { adjustError, oneArgMethods } from "../plugin-system.ts"; @@ -127,14 +127,15 @@ export const spinnerWaiter = Object.assign( return next(); } - // Check for spinner + // Check for loading UI: an app spinner, or a navigation in flight + // (loading UI the app cannot draw itself — see loadingVisible). const spinnerSelector = settings.spinnerSelectors.join(","); const spinnerLocator = page.locator(spinnerSelector) as LocatorWithOriginal; - const spinnerVisible = await anySpinnerVisible(spinnerLocator); + const loading = await loadingVisible(page, spinnerLocator); - if (!spinnerVisible) { - // No spinner - call action, suggest adding one if it fails - settings.log(`${locator} not ready, no spinner, failing fast`); + if (!loading) { + // No spinner, no navigation - call action, suggest a spinner if it fails + settings.log(`${locator} not ready, nothing loading, failing fast`); try { return await next(withTimeoutOption(method, args, 1)); } catch (error) { @@ -144,12 +145,12 @@ export const spinnerWaiter = Object.assign( } settings.log( - `Spinner visible, waiting up to ${settings.spinnerTimeout - 2000}ms for ${locator}`, + `Loading (spinner or navigation), waiting up to ${settings.spinnerTimeout - 2000}ms for ${locator}`, ); - // Spinner is visible — wait for the element, but bail early if the spinner - // disappears (the loading operation finished without producing the expected element). - const waitResult = await waitForReadyWhileSpinning(locator, method, spinnerLocator, { + // Something is loading — wait for the element, but bail early once loading + // finishes (the operation completed without producing the expected element). + const waitResult = await waitForReadyWhileSpinning(locator, method, page, spinnerLocator, { timeout: settings.spinnerTimeout - 2000, }); @@ -160,10 +161,10 @@ export const spinnerWaiter = Object.assign( if (waitResult === "spinner-gone") { settings.log( - `Spinner disappeared but element not ready — loading finished without expected result`, + `Loading finished but element not ready — the operation completed without the expected result`, ); } else { - settings.log(`Spinner still visible after ${settings.spinnerTimeout}ms, UI likely stuck`); + settings.log(`Still loading after ${settings.spinnerTimeout}ms, UI likely stuck`); } // Call action anyway (will likely fail), adjust error message @@ -172,8 +173,8 @@ export const spinnerWaiter = Object.assign( } catch (error) { const message = waitResult === "spinner-gone" - ? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element was not ready.` - : `Spinner was still visible after ${settings.spinnerTimeout}ms, the UI is likely stuck.`; + ? `Loading finished (spinner disappeared / navigation completed after ${Date.now() - start}ms) but the expected element was not ready.` + : `Spinner was still visible after ${settings.spinnerTimeout}ms (or a navigation was still in flight), the UI is likely stuck.`; adjustError(error as Error, [message], "spinner-waiter.ts"); throw error; } @@ -242,28 +243,60 @@ function isOptionsObject(value: unknown): value is Record { } /** - * Wait for `target` to become ready, but bail early if `spinner` disappears. - * Returns "appeared" if target became ready, "spinner-gone" if loading finished - * without the target, or "timeout" if spinner was still visible at deadline. + * Wait for `target` to become ready, but bail early once loading finishes + * (spinner gone and no navigation in flight). Returns "appeared" if target + * became ready, "spinner-gone" if loading finished without the target, or + * "timeout" if still loading at the deadline. */ async function waitForReadyWhileSpinning( target: Locator, method: ActionContext["method"], + page: Page, spinner: Locator, { timeout = 1000 } = {}, ): Promise<"appeared" | "spinner-gone" | "timeout"> { const start = Date.now(); - // Give the spinner a grace period before checking — it may flicker during transitions + // Give loading a grace period before checking — spinners flicker during + // transitions, and one navigation can hand off to another. const spinnerGracePeriodMs = 3000; while (Date.now() - start < timeout) { if (await locatorIsReady(target, method)) return "appeared"; const elapsed = Date.now() - start; - if (elapsed > spinnerGracePeriodMs && !(await anySpinnerVisible(spinner))) return "spinner-gone"; + if (elapsed > spinnerGracePeriodMs && !(await loadingVisible(page, spinner))) return "spinner-gone"; await new Promise((resolve) => setTimeout(resolve, 250)); } return "timeout"; } +/** Loading UI: a document the browser is still loading, or an app spinner. */ +async function loadingVisible(page: Page, spinnerLocator: Locator): Promise { + return (await pageIsNavigating(page)) || (await anySpinnerVisible(spinnerLocator)); +} + +/** + * A document still loading is loading UI the app cannot draw itself. The + * reference is the browser's own tab spinner: it stays on until the document + * fires `load` (readyState !== "complete"), and no execution context to ask + * (the gap while a navigation commits) counts too. Playwright's locator + * queries already wait for a pending navigation to commit, so this covers the + * window after that: a cold page rendering its UI client-side before `load`. + * The spinner grace period and timeout bound it, as for an app spinner. + */ +async function pageIsNavigating(page: Page): Promise { + if (page.isClosed()) return false; + // page.evaluate waits for an execution context; while a navigation is + // committing there is none, so bound the wait and count a stall as loading. + const readyState = await Promise.race([ + page + .evaluate(() => document.readyState) + .catch((error) => + /execution context was destroyed|navigat/i.test(String(error)) ? "loading" : "complete", + ), + new Promise<"no-context">((resolve) => setTimeout(() => resolve("no-context"), 250)), + ]); + return readyState !== "complete"; +} + /** * Multi-element-safe "is any spinner visible": the spinner selector union can * legitimately match several loading indicators at once (e.g. two panels each diff --git a/tasks/complete/2026-08-18-spinner-waiter-navigation-loading.md b/tasks/complete/2026-08-18-spinner-waiter-navigation-loading.md new file mode 100644 index 0000000..4eb0b68 --- /dev/null +++ b/tasks/complete/2026-08-18-spinner-waiter-navigation-loading.md @@ -0,0 +1,66 @@ +--- +status: done +size: small +branch: spinner-waiter-navigation +--- + +# spinner-waiter: an in-flight navigation counts as loading + +**Status summary**: done. spinner-waiter now treats a document that hasn't fired `load` (or has no execution context yet) as loading UI. Two specs; README updated. One limitation recorded below (an action that *initiates* a slow navigation). + +## Why + +`../iterate`'s mobile specs still carry `{ timeout: 15_000 }` on popup actions +even though popups auto-wrap now. The comments say why: the popup is +*mid-navigation* to the auth worker ("the popup event fires before its +cross-server auth navigation mounts the login choices"; "clicks land after +auth-worker navigations that run cold on fresh preview deploys — CI-proven +>1s"). During a navigation the app cannot render a spinner — there is no +document yet — so spinner-waiter sees no loading UI and fast-fails in 1ms. +But the browser itself is visibly loading. That is loading UI by any honest +reading of the plugin's rule ("if the app is visibly loading, wait longer"). + +## Decision + +Treat an in-flight navigation exactly like a visible spinner: + +- the current document hasn't fired `load` yet (`readyState !== "complete"` — + the browser's own tab spinner is the reference; the grace period and + spinner timeout bound it as they do an app spinner), or +- no execution context to ask — the gap while a navigation commits. + +Not needed (found out by instrumenting): tracking pending main-frame requests. +Playwright's locator queries (`isVisible`, `count`) already block until a +pending navigation commits, so spinner-waiter never observes that phase; the +reachable window is *after* commit, before `load` — a cold page rendering its +UI client-side. A first cut carried a request tracker; it was dead code and is +gone. + +A closed page is not loading. The same predicate feeds both the initial +"is anything loading?" check and the "loading finished without the target" +bail-out, so error hints stay accurate ("Loading finished (spinner gone / +navigation done)"). + +Out of scope: + +- `page.waitForEvent("popup", { timeout })` — an event wait, not a locator + action; middlewright never sees it and the fix there is product loading UI + on the button that triggers the auth round-trips. +- An action that *initiates* a slow navigation (clicking a link to a cold + server): Playwright's click waits for the navigation it started, inside the + action's own timeout, after spinner-waiter has already let it through. Found + while writing the spec (the anchor click itself hit the 1s budget). Worth its + own task if it bites for real; the popup case doesn't trigger it because the + popup arrives already navigating. + +## Checklist + +- [x] `pageIsNavigating(page)` predicate; fold into the loading checks _(`loadingVisible` = navigating || spinner, checked navigation-first; used for both the initial check and the loading-finished bail-out)_ +- [x] spec: a click whose target only exists after a slow cross-page navigation (no app spinner) succeeds without an explicit timeout _("waits while a freshly navigated document is still loading" — proven load-bearing by disabling the check: fast-fails at 1ms without it)_ +- [x] spec: navigation completes but the target never appears → fast fail after the navigation _(the ordinary no-spinner fast-fail once the document is complete — Playwright had already waited out the commit, so the loading branch isn't what's exercised there)_ +- [x] README: mention navigation counts as loading under spinnerWaiter + +## Implementation log + +- 2026-08-18: found via `../iterate` specs (`specs/mobile/{notifications,approvals}.spec.ts`). +- 2026-08-18: implemented; instrumented to learn Playwright already blocks locator queries until commit, dropped the request tracker accordingly. 154 tests green. diff --git a/tasks/spinner-waiter-navigation-loading.md b/tasks/spinner-waiter-navigation-loading.md deleted file mode 100644 index 0f3d0e2..0000000 --- a/tasks/spinner-waiter-navigation-loading.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -status: in-progress -size: small -branch: spinner-waiter-navigation ---- - -# spinner-waiter: an in-flight navigation counts as loading - -**Status summary**: spec'd, implementing. Small change to `src/plugins/spinner-waiter.ts` plus a spec. - -## Why - -`../iterate`'s mobile specs still carry `{ timeout: 15_000 }` on popup actions -even though popups auto-wrap now. The comments say why: the popup is -*mid-navigation* to the auth worker ("the popup event fires before its -cross-server auth navigation mounts the login choices"; "clicks land after -auth-worker navigations that run cold on fresh preview deploys — CI-proven ->1s"). During a navigation the app cannot render a spinner — there is no -document yet — so spinner-waiter sees no loading UI and fast-fails in 1ms. -But the browser itself is visibly loading. That is loading UI by any honest -reading of the plugin's rule ("if the app is visibly loading, wait longer"). - -## Decision - -Treat an in-flight navigation exactly like a visible spinner: - -- `document.readyState === "loading"` (until DOMContentLoaded — deliberately - not `"interactive"`, so slow subresources can't mask a broken page), or -- no execution context to ask (`page.evaluate` throws "Execution context was - destroyed … navigation") — the gap between a navigation's commit and its new - document. - -A closed page is not loading. The same predicate feeds both the initial -"is anything loading?" check and the "loading finished without the target" -bail-out, so error hints stay accurate ("Loading finished (spinner gone / -navigation done)"). - -Out of scope: `page.waitForEvent("popup", { timeout })` — an event wait, not a -locator action; middlewright never sees it and the fix there is product -loading UI on the button that triggers the auth round-trips. - -## Checklist - -- [ ] `pageIsNavigating(page)` predicate; fold into the loading checks -- [ ] spec: a click whose target only exists after a slow cross-page navigation (no app spinner) succeeds without an explicit timeout -- [ ] spec: navigation completes but the target never appears → fast fail after the navigation, with the loading-finished hint -- [ ] README: mention navigation counts as loading under spinnerWaiter - -## Implementation log - -- 2026-08-18: found via `../iterate` specs (`specs/mobile/{notifications,approvals}.spec.ts`).