Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 45 additions & 2 deletions llm-docs/playwright-best-practices.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
main_commit: ee0f68be1
analyzed_date: 2026-02-27
analyzed_date: 2026-09-14
key_files:
- tests/integration/playwright/tests/axe-accessibility.spec.ts
- tests/integration/playwright/tests/html-math-katex.spec.ts
- tests/integration/playwright/tests/book-back-to-top.spec.ts
---

# Playwright Testing Best Practices
Expand Down Expand Up @@ -273,6 +274,46 @@ async function rescan() {

**From PR #14125 dashboard rescan:** Users can switch tabs/pages faster than axe scans complete. Generation counters ensure old scans don't overwrite newer results.

## Scroll-State Race in Direction-Tracking Handlers

When application code tracks scroll *direction* (not just position) via a variable updated inside a `scroll` event handler, back-to-back `page.evaluate(() => window.scrollTo(...))` calls can race that handler: the second scroll can fire before the first scroll's event has been dispatched and processed, so the handler's internal state is stale when the direction check runs.

### Pattern

```typescript
// ❌ Bad - second scrollTo can race the first scroll event's handler
await page.evaluate((top) => window.scrollTo({ top, behavior: "instant" }), viewportHeight);
await expect(backToTop).toBeHidden(); // passes trivially, doesn't prove the event ran

await page.evaluate((top) => window.scrollTo({ top, behavior: "instant" }), viewportHeight / 4);
await expect(backToTop).toBeVisible(); // may flake: handler's tracked position is stale

// ❌ Also bad - polling window.scrollY doesn't prove the handler ran.
// scrollTo({behavior: "instant"}) updates scrollY synchronously, before the
// "scroll" event even dispatches, so the poll's first check passes for free.
await page.evaluate((top) => window.scrollTo({ top, behavior: "instant" }), viewportHeight);
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(viewportHeight);
await expect(backToTop).toBeHidden();

// ✅ Good - await the "scroll" event itself via a small helper (see
// `scrollToAndSettle` in book-back-to-top.spec.ts). Listeners for the same
// event fire in registration order, so a listener registered here always
// runs after the page's own already-registered listener.
await scrollToAndSettle(viewportHeight);
await expect(backToTop).toBeHidden();

await scrollToAndSettle(viewportHeight / 4);
await expect(backToTop).toBeVisible();
```

### Why Not Just Assert on the Visible UI State, or Poll scrollY?

An assertion like `toBeHidden()` right after the down-scroll passes trivially when the element is already hidden by default — it proves nothing about whether the scroll event handler ran and updated its internal tracker. Polling `window.scrollY` looks like it forces an event-loop turn, but it doesn't wait for anything: `scrollTo` updates `scrollY` synchronously, so the poll's first check already succeeds, before the queued `"scroll"` event has even been dispatched to any listener. The only way to know the handler actually ran is to wait on the same event it's listening for.

**Real-world example (PR #14889, `book-back-to-top.spec.ts`):** `quarto-nav.js`'s back-to-top button tracks `lastScrollTop`, updated only inside its `scroll` listener on a hide/show transition. A scroll-down immediately followed by a scroll-up raced that listener on `chromium`/`webkit`/`firefox` in CI — the up-scroll's direction check compared against a stale `lastScrollTop` of `0` and never showed the button. An initial fix that polled `window.scrollY` between the two scrolls still failed this exact race in roughly 1 of 5 repeated local runs; switching to a one-shot `window.addEventListener("scroll", ..., { once: true })` awaited via a `Promise` eliminated it (45/45 passing across three browsers, verified with `--repeat-each=15`).

## Parameterized Tests

When testing the same behavior across multiple formats or configurations, use `test.describe` with a test cases array instead of separate spec files.
Expand Down Expand Up @@ -349,12 +390,13 @@ test('Feature that is broken in revealjs', async ({ page }) => {

## Summary

**Four key patterns for reliable Playwright tests:**
**Five key patterns for reliable Playwright tests:**

1. **Web-first assertions** - `expect(el).toContainText()` not `expect(await el.textContent())`
2. **Role-based selectors** - `getByRole('tab', { name: 'Page 2' })` not `locator('a[data-bs-target]')`
3. **Explicit .first() comments** - Explain why and what you're testing
4. **Completion signals** - `data-feature-complete` in finally blocks, not arbitrary delays
5. **Await the scroll event between reversed scrolls** - `addEventListener("scroll", ..., { once: true })` awaited via a `Promise` before reversing direction, when app code tracks scroll direction via a handler-updated variable

These patterns emerged from building comprehensive cross-format test coverage and debugging race conditions. They make tests:
- More reliable (fewer flaky failures)
Expand All @@ -365,3 +407,4 @@ These patterns emerged from building comprehensive cross-format test coverage an
**Reference implementations:**
- `tests/integration/playwright/tests/axe-accessibility.spec.ts` - 431 lines, 75 test cases
- `tests/integration/playwright/tests/html-math-katex.spec.ts` - Parameterized format testing
- `tests/integration/playwright/tests/book-back-to-top.spec.ts` - Awaiting the scroll event between reversed scrolls
4 changes: 4 additions & 0 deletions tests/docs/playwright/book/back-to-top/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/.quarto/
/_book/

**/*.quarto_ipynb
12 changes: 12 additions & 0 deletions tests/docs/playwright/book/back-to-top/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
project:
type: book

book:
title: "Back To Top Book"
back-to-top-navigation: true
chapters:
- index.qmd

format:
html:
theme: cosmo
14 changes: 14 additions & 0 deletions tests/docs/playwright/book/back-to-top/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
title: "Preface"
---

# Preface {.unnumbered}

Regression test for #14879: `back-to-top-navigation` set under the `book`
key must control the book website output, not just `website:`. This page
is tall enough to scroll and exercise the show/hide/click-to-top behavior
of the `#quarto-back-to-top` control.

<div style="height: 300vh;"></div>

End of page.
61 changes: 61 additions & 0 deletions tests/integration/playwright/tests/book-back-to-top.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import { getUrl } from "../src/utils";

// Regression test for #14879. `back-to-top-navigation` set under the `book`
// key (rather than `website`) used to be silently dropped, so the
// #quarto-back-to-top control never appeared in book output. This exercises
// the control's full show/hide/click-to-top behavior once the control is
// present, not just its injection into the HTML.

// Disable the reduced-motion-gated `scroll-behavior: smooth` CSS so
// window.scrollTo takes effect synchronously, and pass an explicit
// "instant" behavior on every scroll to avoid animated-scroll flake.
test.use({ reducedMotion: "reduce" });

test("book-level back-to-top control shows/hides on scroll and returns to top on click", async ({
page,
}) => {
await page.goto(getUrl("book/back-to-top/_book/index.html"), {
waitUntil: "load",
});

const backToTop = page.locator("#quarto-back-to-top");

// Scrolls to `top` and waits for the page's own `scroll` listener (the one
// quarto-nav.js uses to update its internal lastScrollTop tracker) to have
// run, so a following scroll in the opposite direction can't race it.
const scrollToAndSettle = (top: number) =>
page.evaluate(
(top) =>
new Promise<void>((resolve) => {
window.addEventListener("scroll", () => resolve(), { once: true });
window.scrollTo({ top, behavior: "instant" });
}),
top,
);

// 1. At the top of the page: control is attached but hidden.
await expect(backToTop).toBeAttached();
await expect(backToTop).toBeHidden();

// 2. Scroll down one viewport: still hidden (downward scroll hides it).
const viewportHeight = page.viewportSize()?.height ?? 720;
await scrollToAndSettle(viewportHeight);
await expect(backToTop).toBeHidden();

// 3. Scroll up (past the up-buffer threshold): control becomes visible.
await scrollToAndSettle(Math.floor(viewportHeight / 4));
await expect(backToTop).toBeVisible();

// 4. Jump to the bottom of the page: visible via the bottom-of-page branch.
await page.evaluate(() =>
window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" })
);
await expect(backToTop).toBeVisible();

// 5. Click the control: it scrolls to the top and hides itself again.
// No further scroll follows this one, so the web-first assertion's own
// retry is enough to wait out the scroll handler.
await backToTop.click();
await expect(backToTop).toBeHidden();
});
Loading