Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
57 changes: 57 additions & 0 deletions spec/spinner-waiter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<div id="slot"></div>
<script>
setTimeout(() => {
document.querySelector('#slot').innerHTML = '<button onclick="this.textContent = \\'consented\\'">Allow access</button>';
}, 2500);
</script>
`);
// 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(`<div id="empty"></div>`);
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(`
<div id="banner">temporary banner</div>
<div id="fixture">permanent fixture</div>
<script>
setTimeout(() => document.querySelector('#banner').remove(), 500);
</script>
`);

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);
});
49 changes: 48 additions & 1 deletion src/plugins/spinner-waiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down Expand Up @@ -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();
}
Expand All @@ -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];
Expand Down
Loading