Skip to content
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,33 @@ test("a test where spinners are expected to hang", async ({ page }) => {
});
```

### motionWaiter

Waits for a *moving* target to settle before pointer actions (`click`, `dblclick`, `hover`). Playwright's own stability check only compares the target's bounding box across two consecutive frames, so timer-driven JS animation (React Native web's `Animated`, `setInterval` steppers) that steps coarser than the display refresh gets clicked mid-slide — the click lands on a half-open drawer, and video-mode's click-moment freeze bakes the clipped panel into the recording.

motionWaiter samples the target's bounding box over a longer window: the action proceeds only once the box has been observed holding still for `settledFor` (~150ms — the per-action cost on static elements, and a real window on purpose: an element often sits parked for a frame or two before its animation starts, React Native's open → requestAnimationFrame → animate shape).

Because every guarded action pays that stillness window, the plugin is **off by default**: register it, then opt in around the specific interactions whose animations are known to defeat Playwright's stability check —

```ts
await motionWaiter.settings.run({ enabled: true }, () =>
page.getByRole("button", { name: "Notifications" }).click(),
);
```

— or pass `enabled: true` at registration to guard a whole suite. The wait is budgeted — perpetual motion (marquees, rotating icons) proceeds at `settleTimeout` with a log line instead of blocking. Opacity-only fades never engage it (the box doesn't move), and step cadences slower than `sampleInterval` can pass the initial check, same as vanilla Playwright.

```ts
motionWaiter({
enabled: true, // guard every pointer action (default: false — opt in per block)
settleTimeout: 1_500, // max wait for motion to stop
sampleInterval: 60, // ms between bounding-box samples
settledFor: 150, // stillness window required before proceeding
});
```

Register it after `spinnerWaiter` (`plugins: [spinnerWaiter(), motionWaiter()]`): spinner-waiter's fast-fail path passes an explicit 1ms timeout inward, and motionWaiter — like spinnerWaiter itself — treats an explicit `{ timeout }` as the author taking charge of timing and passes straight through. The same `settings.enterWith` / `settings.run` runtime overrides apply — `enterWith` turns it on for the rest of a test, `run` scopes it to one action.

### hydrationWaiter

Before each action, waits for `[data-hydrated="false"]` to disappear. Your app cooperates by rendering that attribute server-side and flipping it once the framework hydrates. Stops the classic "test clicked a button before React attached the handler" flake at the source.
Expand Down
2 changes: 2 additions & 0 deletions spec/lint-plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ test("reports timeout options without an explanation", async () => {
const output = `${result.stdout}\n${result.stderr}`;
expect(output).toContain("middlewright(require-timeout-comment)");
expect(output).toContain("remove the timeout and add loading UI for spinnerWaiter");
expect(output).toContain("motionWaiter.settings.run({ enabled: true }");
expect(output).toContain(
"https://github.com/iterate/middlewright#dont-fix-slow-tests-with-longer-timeouts",
);
Expand Down Expand Up @@ -448,6 +449,7 @@ test("reports bare waitForTimeout sleeps", async () => {
const output = `${result.stdout}\n${result.stderr}`;
expect(output).toContain("middlewright(require-timeout-comment)");
expect(output).toContain("a sleep waits whether or not the app is ready");
expect(output).toContain("motionWaiter.settings.run({ enabled: true }");
expect(await readFile(fixture.sourcePath, "utf8")).toBe(source);
});

Expand Down
173 changes: 173 additions & 0 deletions spec/motion-drawer-demo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { expect, test } from "@playwright/test";
import { addPlugins, motionWaiter, videoMode } from "../src/index.ts";

test.use({
video: "on",
viewport: { height: 720, width: 480 },
});

// A drawer that slides in over 700ms, stepped by a JS TIMER (the React Native
// web Animated / legacy jQuery shape). The 48ms steps are coarser than the
// display refresh, so plenty of consecutive frame pairs are identical
// mid-slide — which defeats Playwright's own two-frame stability check.

test("control: without motion-waiter the click lands on a still-sliding drawer", async ({
page: basePage,
}, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
// The heading's appearance needs no highlight of its own — footage runs
// continuously from the click through the app's pressed-flash and fade.
plugins: [videoMode({ skipMethods: ["waitFor"] })],
});
await page.setContent(getDrawerAppHtml());

await page.videoMode.caption("Open the menu: the drawer starts sliding in", () =>
page.getByRole("button", { name: "Open menu" }).click(),
);
await page.videoMode.caption("Playwright clicks while the drawer is still sliding", () =>
page.getByRole("button", { name: "Notifications" }).click(),
);
// timeout: the app holds a 700ms pressed flash + 200ms fade before navigating — nothing there for a spinner-waiter
await page.getByRole("heading", { name: "Notifications" }).waitFor({ timeout: 5_000 });
// Outlast the menu's fade-out so the recording ends on a settled screen.
// timeout: fade completion, invisible to the spinner-waiter
await page.locator("#overlay").waitFor({ state: "hidden", timeout: 5_000 });

// The app records the drawer's translateX at the moment the click landed.
// Vanilla Playwright clicks while the drawer is still well off to the left —
// the recording freezes a half-open drawer under the click pointer.
const drawerXAtClick = await page.evaluate(() => (window as any).__drawerXAtClick);
console.log(`drawer translateX at click: ${drawerXAtClick}px (drawer is 280px wide)`);
expect(drawerXAtClick).toBeLessThan(-40);
});

test("opting in for the drawer click waits out the slide", async ({
page: basePage,
}, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
// motionWaiter OUTSIDE videoMode: its settle hold completes before
// video-mode reads the action timing, so the flagged watchable span (the
// drawer's slide) renders at full speed instead of compressing away.
plugins: [motionWaiter(), videoMode({ skipMethods: ["waitFor"] })],
});
await page.setContent(getDrawerAppHtml());

await page.videoMode.caption("Open the menu: the drawer starts sliding in", () =>
page.getByRole("button", { name: "Open menu" }).click(),
);
await page.videoMode.caption("motion-waiter holds the click until the drawer settles", () =>
motionWaiter.settings.run({ enabled: true }, () =>
page.getByRole("button", { name: "Notifications" }).click(),
),
);
// timeout: the app holds a 700ms pressed flash + 200ms fade before navigating — nothing there for a spinner-waiter
await page.getByRole("heading", { name: "Notifications" }).waitFor({ timeout: 5_000 });
// Outlast the menu's fade-out so the recording ends on a settled screen.
// timeout: fade completion, invisible to the spinner-waiter
await page.locator("#overlay").waitFor({ state: "hidden", timeout: 5_000 });

// Same app, same clicks — the one opted-in click was held until the slide
// finished, so it landed on the drawer at rest (translateX ≈ 0).
const drawerXAtClick = await page.evaluate(() => (window as any).__drawerXAtClick);
console.log(`drawer translateX at click: ${drawerXAtClick}px (drawer is 280px wide)`);
expect(drawerXAtClick).toBeGreaterThan(-1);
});

function getDrawerAppHtml() {
return `
<!doctype html>
<html>
<head>
<style>
* { margin: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #f4f4f5; }
header { display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #18181b; color: #fafafa; }
header button { font-size: 18px; background: none; color: inherit; border: 1px solid #3f3f46; border-radius: 8px; padding: 6px 10px; }
main { padding: 24px 16px; }
#overlay {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
opacity: 0; transition: opacity 180ms ease;
}
#overlay.open { opacity: 1; }
#drawer {
position: fixed; top: 0; bottom: 0; left: 0; width: 280px;
background: #ffffff; box-shadow: 4px 0 24px rgba(0, 0, 0, 0.25);
padding: 20px 16px; transform: translateX(-280px);
}
#drawer h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; color: #71717a; margin-bottom: 12px; }
#drawer button {
display: flex; width: 100%; padding: 13px 12px; margin-bottom: 4px;
font-size: 16px; text-align: left; background: none; border: none; border-radius: 8px;
}
#drawer button:hover { background: #f4f4f5; }
</style>
</head>
<body>
<header>
<button aria-label="Open menu">☰</button>
<strong>Drawer demo</strong>
</header>
<main id="screen">
<h1>Home</h1>
<p>Open the menu and pick a section.</p>
</main>
<div id="overlay" hidden>
<div id="drawer">
<h2>Menu</h2>
<button data-screen="Agents">Agents</button>
<button data-screen="Notifications">Notifications</button>
<button data-screen="Settings">Settings</button>
</div>
</div>
<script>
const overlay = document.getElementById("overlay");
const drawer = document.getElementById("drawer");
const DRAWER_WIDTH = 280;
const SLIDE_MS = 700;
const STEP_MS = 48; // JS-timer stepping, coarser than the display refresh
let slideTimer;

document.querySelector("header button").addEventListener("click", () => {
overlay.hidden = false;
requestAnimationFrame(() => overlay.classList.add("open"));
const startedAt = Date.now();
slideTimer = setInterval(() => {
const progress = Math.min((Date.now() - startedAt) / SLIDE_MS, 1);
const eased = 1 - Math.pow(1 - progress, 3);
drawer.style.transform = "translateX(" + -DRAWER_WIDTH * (1 - eased) + "px)";
if (progress >= 1) clearInterval(slideTimer);
}, STEP_MS);
});

for (const item of drawer.querySelectorAll("button[data-screen]")) {
item.addEventListener("click", () => {
const transform = new DOMMatrixReadOnly(getComputedStyle(drawer).transform);
window.__drawerXAtClick = transform.m41;
// A pressed-item flash before navigating, like a real app. The
// slide freezes with it — so a mid-slide click visibly strands
// the drawer part way out.
clearInterval(slideTimer);
item.style.background = "#e4e4e7";
setTimeout(() => {
// Navigate as the menu starts fading, so the new screen is
// what the fade reveals.
document.getElementById("screen").innerHTML =
"<h1>" + item.dataset.screen + "</h1><p>The " + item.dataset.screen.toLowerCase() + " screen.</p>";
overlay.classList.remove("open");
setTimeout(() => {
overlay.hidden = true;
drawer.style.transform = "translateX(" + -DRAWER_WIDTH + "px)";
item.style.background = "";
}, 200);
}, 700);
});
}
</script>
</body>
</html>
`;
}
119 changes: 119 additions & 0 deletions spec/motion-waiter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { test as base, expect } from "@playwright/test";
import { addPlugins, motionWaiter, videoMode } from "../src/index.ts";

const test = base.extend({
page: async ({ page: basePage }, use, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [motionWaiter()],
});
await page.setContent(getMovingButtonHtml());
await use(page);
},
});

test("a static element clicks after a short stillness check", async ({ page }) => {
motionWaiter.settings.enterWith({ enabled: true });
const start = Date.now();
await page.getByRole("button", { name: "static" }).click();
expect(Date.now() - start).toBeLessThan(700);
await page.getByText("clicked static at x=0").waitFor();
});

test("a timer-stepped slide is waited out; the click lands at rest", async ({ page }) => {
motionWaiter.settings.enterWith({ enabled: true });
// 800ms slide, stepped every 48ms — identical consecutive display frames
// mid-slide, so vanilla Playwright would click while it moves.
await page.evaluate(() => (window as any).startSlide(200, 800));
await page.getByRole("button", { name: "sliding" }).click();
await page.getByText("clicked sliding at x=200").waitFor();
});

test("off by default: the same click lands mid-slide until a block opts in", async ({ page }) => {
await page.evaluate(() => (window as any).startSlide(200, 800));
await page.getByRole("button", { name: "sliding" }).click();
const clickedAt = await page.evaluate(() => (window as any).__clickedAtX);
expect(clickedAt).toBeGreaterThanOrEqual(0);
expect(clickedAt).toBeLessThan(200);
});

test("perpetual motion proceeds once the settle budget runs out", async ({ page }) => {
motionWaiter.settings.enterWith({ enabled: true });
await page.evaluate(() => (window as any).startMarquee());
const start = Date.now();
await page.getByRole("button", { name: "sliding" }).click();
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(motionWaiter.defaults.settleTimeout);
await page.getByText(/clicked sliding at x=\d/).waitFor();
});

test("an explicit timeout passes straight through, like spinner-waiter's escape hatch", async ({
page,
}) => {
motionWaiter.settings.enterWith({ enabled: true });
await page.evaluate(() => (window as any).startSlide(200, 800));
// timeout: the explicit-timeout escape hatch IS the subject under test — motion-waiter passes through, like spinner-waiter does
await page.getByRole("button", { name: "sliding" }).click({ timeout: 5_000 });
const clickedAt = await page.evaluate(() => (window as any).__clickedAtX);
expect(clickedAt).toBeLessThan(200);
});

base("a motion hold is flagged watchable, so video-mode keeps the footage", async ({
page: basePage,
}, testInfo) => {
const video = videoMode({ finalHold: 0, highlight: false });
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [motionWaiter(), video],
});
await page.setContent(getMovingButtonHtml());
motionWaiter.settings.enterWith({ enabled: true });

await page.evaluate(() => (window as any).startSlide(200, 800));
await page.getByRole("button", { name: "sliding" }).click();

// The ~800ms settle hold precedes video-mode's middleware, which would
// normally record it as one fat dead-air span and compress the slide away.
// The watchable flag carves it out — no dead-air span may span the hold.
const { deadAir } = await video.metadata();
expect(deadAir.filter((span) => span.end - span.start > 400)).toEqual([]);
});

function getMovingButtonHtml() {
return `
<body>
<button id="static-button" style="position: fixed; top: 200px; left: 0">static</button>
<button id="sliding-button" style="position: fixed; top: 100px; left: 0">sliding</button>
<div id="result"></div>
<script>
const sliding = document.getElementById("sliding-button");
for (const button of document.querySelectorAll("button")) {
button.addEventListener("click", () => {
window.__clickedAtX = Math.round(button.getBoundingClientRect().x);
document.getElementById("result").textContent =
"clicked " + button.textContent + " at x=" + window.__clickedAtX;
});
}
// A JS-timer slide from x=0 to x=toX — steps coarser than the display
// refresh, the cadence Playwright's two-frame stability check misses.
window.startSlide = (toX, durationMs) => {
const startedAt = Date.now();
const timer = setInterval(() => {
const progress = Math.min((Date.now() - startedAt) / durationMs, 1);
sliding.style.left = toX * progress + "px";
if (progress >= 1) clearInterval(timer);
}, 48);
};
window.startMarquee = () => {
let x = 0;
setInterval(() => {
x = (x + 8) % 200;
sliding.style.left = x + "px";
}, 40);
};
</script>
</body>
`;
}
2 changes: 2 additions & 0 deletions src/lint/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const requireTimeoutComment = {
- If there's no loading UI, remove the timeout and add loading UI for spinnerWaiter.
- If there's a loading UI but it takes even longer than the spinnerWaiter spinner timeout, use \`await spinnerWaiter.settings.run({ spinnerTimeout: 123_456 }, async () => ...)\` or similar to wait even longer for the spinner to complete.
- If there's loading UI from a part of the code that we don't control (e.g. a library) and it isn't matched by the default spinner selectors, use \`await spinnerWaiter.settings.run({ spinnerSelectors: ["myCustomSpinnerClass"] }, async () => ...)\`.
- If the wait is really about an ANIMATION (a sliding drawer, a settling panel), don't time it — use \`await motionWaiter.settings.run({ enabled: true }, () => locator.click())\` to wait for the target to stop moving.
- If it is truly impossible for there to be loading UI, add a nearby // comment matching every required pattern: {{patterns}}.
- If you're in a block which has done \`await spinnerWaiter.settings.run({ disabled: true }, async () => ...)\`, you should probably *un-disable* for that block and apply the above suggestions to the inner code.

Expand All @@ -109,6 +110,7 @@ const requireTimeoutComment = {
sleep: dedent`
Avoid waitForTimeout — a sleep waits whether or not the app is ready. Best ways to resolve:
- Wait for positive UI instead: a locator wait covers readiness, and spinnerWaiter extends it while loading UI shows.
- If the sleep lets an ANIMATION finish before clicking (a sliding drawer, a settling panel), use \`await motionWaiter.settings.run({ enabled: true }, () => locator.click())\` — it waits for the target to stop moving instead of guessing a duration.
- If the sleep paces a recording, let video mode pace instead (it holds popup entry and settles the recorder itself); still-needed manual pacing is a library gap worth filing.
- If it is truly necessary, add a nearby // comment matching every required pattern: {{patterns}}.

Expand Down
7 changes: 7 additions & 0 deletions src/plugin-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ export type ActionTiming = {
attachedAt?: number;
attachedAtStart: boolean;
middlewares: ActionMiddlewareTiming[];
/**
* `performance.now()` spans a middleware flags as watchable footage — a
* settle wait spanning a real on-screen animation, say. Video renderers
* keep these at full speed instead of compressing them as dead air.
*/
watchableSpans: { startedAt: number; endedAt: number }[];
};

/** Function that calls the next middleware or the original action */
Expand Down Expand Up @@ -453,6 +459,7 @@ const patchLocatorPrototype = (
attachedAt: attachedAtStart ? actionStartedAt : undefined,
attachedAtStart,
middlewares: [],
watchableSpans: [],
};
const stopObservingAttached = attachedAtStart
? () => {}
Expand Down
1 change: 1 addition & 0 deletions src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
type VideoModeSpan,
type VideoModeViewport,
} from "./video-mode.ts";
export { motionWaiter, type MotionWaiterOptions } from "./motion-waiter.ts";
export { spinnerWaiter, type SpinnerWaiterOptions, defaultSelectors } from "./spinner-waiter.ts";
export { uiErrorReporter, type UIErrorReporterOptions } from "./ui-error-reporter.ts";
export {
Expand Down
Loading
Loading