diff --git a/spec/spinner-waiter.spec.ts b/spec/spinner-waiter.spec.ts index bea0bda..b55aba3 100644 --- a/spec/spinner-waiter.spec.ts +++ b/spec/spinner-waiter.spec.ts @@ -199,3 +199,60 @@ test("bails early when spinner disappears without expected element", async ({ pa // Should bail within ~10s (2s spinner + 3s grace + buffer), not wait full 30s expect(elapsed).toBeLessThan(15_000); }); + +test("an explicit timeout is honored instead of the 1ms fast-fail", async ({ page }) => { + // The element appears after 2.5s with NO spinner — normally the fast-fail + // path (the "add a spinner" nudge). An explicit timeout is the author's + // owned budget for exactly this shape (auth pages without loading UI), so + // the action passes through and playwright waits it out. + await page.setContent(` +
+ + `); + // timeout: deliberate spinner-waiter escape hatch — the pass-through under test + await page.getByRole("button", { name: "Allow access" }).click({ timeout: 15_000 }); + await page.getByText("consented").waitFor(); +}); + +test("an exceeded explicit timeout still fails with its own budget, not 1ms", async ({ page }) => { + await page.setContent(`
`); + const start = Date.now(); + const error = await page + .getByRole("button", { name: "Never appears" }) + // timeout: deliberate spinner-waiter escape hatch — the pass-through under test + .click({ timeout: 2000 }) + .catch((e: Error) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/Timeout 2000ms exceeded/); + expect(Date.now() - start).toBeGreaterThan(1500); +}); + +test("disappearance waits pass through untouched", async ({ page }) => { + // waitFor({ state: "detached" | "hidden" }) waits for the target to LEAVE — + // spinner-waiter's appear-oriented model doesn't apply, so those waits get + // vanilla Playwright behavior: a satisfied wait resolves (no 1ms fast-fail + // aborting it), an unsatisfied one fails on the normal action timeout. + await page.setContent(` + +
permanent fixture
+ + `); + + await page.getByText("temporary banner").waitFor({ state: "hidden" }); + + const start = Date.now(); + const error = await page + .getByText("permanent fixture") + .waitFor({ state: "hidden" }) + .catch((e: Error) => e); + expect(error).toBeInstanceOf(Error); + // The configured 1s actionTimeout, not spinner-waiter's 1ms fast-fail. + expect(String(error)).toContain("Timeout 1000ms exceeded"); + expect(Date.now() - start).toBeGreaterThan(500); +}); diff --git a/src/plugins/spinner-waiter.ts b/src/plugins/spinner-waiter.ts index 74e8c24..7dc7d59 100644 --- a/src/plugins/spinner-waiter.ts +++ b/src/plugins/spinner-waiter.ts @@ -88,6 +88,35 @@ export const spinnerWaiter = Object.assign( const settings = getSettings(options); if (settings.disabled) return next(); + // An explicitly passed { timeout } is the test author saying "I know + // there is no spinner here; use this budget" — the same escape hatch + // as settings.run({ disabled: true }) but scoped to one action. Pass + // straight through: overriding it with the 1ms fast-fail would turn a + // deliberate long wait into a guaranteed failure (bitten in practice + // when popup auto-wrap put previously-raw popup actions, timeouts and + // all, behind this middleware). + const authorTimeout = explicitTimeout(method, args); + if (authorTimeout !== undefined) { + settings.log( + `${locator}.${method}(...) carries an explicit ${authorTimeout}ms timeout — passing through`, + ); + return next(); + } + + // waitFor({ state: "detached" | "hidden" }) waits for the target to + // LEAVE. Spinner-waiter's whole model — fail fast unless visible + // loading UI justifies waiting — is about things appearing; inverted + // for disappearance it turns nonsensical (the visible "spinner" may + // be the very thing that's disappearing). Those waits are + // lint-discouraged in favor of positive waits; where one exists it + // gets vanilla Playwright behavior. + if (isDisappearanceWait(method, args)) { + settings.log( + `${locator}.waitFor({ state: detached|hidden }) — spinner-waiter does not deal with disappearance waits, passing through`, + ); + return next(); + } + const start = Date.now(); settings.log(`${locator}.${method}(...) starting`); @@ -159,8 +188,19 @@ export const spinnerWaiter = Object.assign( }, ); +/** waitFor({ state: "detached" | "hidden" }) — the target leaving the page. */ +function isDisappearanceWait(method: ActionContext["method"], args: unknown[]) { + const options = args[0]; + return ( + method === "waitFor" && + isOptionsObject(options) && + (options.state === "detached" || options.state === "hidden") + ); +} + async function locatorIsReady(locator: Locator, method: ActionContext["method"]) { - if (!(await locator.isVisible())) return false; + const visible = await locator.isVisible(); + if (!visible) return false; if (!enabledActionMethods.has(method)) return true; return await locator.isEnabled(); } @@ -178,6 +218,13 @@ async function waitForReady( return await locatorIsReady(locator, method); } +/** The author-passed timeout option for this action, if any. */ +function explicitTimeout(method: ActionContext["method"], args: unknown[]): number | undefined { + const options = args[oneArgMethodNames.has(method) ? 1 : 0]; + if (!isOptionsObject(options)) return undefined; + return typeof options.timeout === "number" ? options.timeout : undefined; +} + function withTimeoutOption(method: ActionContext["method"], args: unknown[], timeout: number) { const optionsIndex = oneArgMethodNames.has(method) ? 1 : 0; const nextArgs = [...args];