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(`
+ temporary banner
+ 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];