From 478f46b8af81850bc247f6bd004e4b962286d1e2 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:07:20 +0000 Subject: [PATCH] The fix is in `propr-ui/e2e/list-search-lazy-navigation.pw.ts` and nothing else changed. The full matrix passes in Chromium and WebKit: 24/24 in each (the 21 original cases plus 3 new ones), with zero retries. Cause. `pauseAt` moves the clock forward to an absolute time and rejects any target in the past. The fake clock follows real time until the pause actually runs in the page, so "sampled time + 50 ms" is already in the past whenever the command takes longer than 50 ms. In the failing run it took about 106 ms. Fix. The pause helper now uses three supported clock calls instead of a margin: 1. Read the page's current time `t`. 2. `page.clock.setFixedTime(t)` freezes the page's Date at `t`. Timers keep running on real time, which is the same as before the pause lands. 3. `page.clock.pauseAt(t)` now has to move the clock forward by exactly 0 ms, however late it arrives. It can't land in the past, and unlike the old +50 ms it can't fire a pending timer early. 4. `page.clock.setSystemTime(t)` releases Date while the clock is paused, so it stays stopped at `t`. Everything else in the file is unchanged: the 400/300 ms production debounces, the 1,000 ms advance, the held real Settings chunk, the input, URL and focus checks, the Settings and Back assertions, and the timeouts. I made no production, backend or dependency changes. Nothing needed a browser-specific workaround: Chromium and WebKit run the same code. Latency proof (criterion 4). The helper takes an optional `stall` hook that runs between each step. One new control case per viewport passes a fixed 250 ms wait at every step, which is more than the old 50 ms margin and the observed 106 ms. That case then: - checks the page clock reads exactly `t` after the pause. - types a Tasks search and checks the clock is still at `t` and the URL has no `search=`. - runs the full Settings navigation and Back checks. This is a fixed delay, not a timing benchmark, and nothing is retried. I also ran two throwaway checks that I didn't keep in the repo: - Old helper under the same 250 ms stall: it fails every time with `Cannot fast-forward to the past`. - Debounce still queued: for Tasks, Plans and Goals, after the new pause the typed search didn't reach the URL after 1.5 s of real time. Advancing the clock 1,000 ms while staying on the list then did write it, so the debounce was waiting, not dropped. How it was checked: - Chromium: the default config. - WebKit: a temporary config, now deleted, using browser libraries I installed locally in this container. - Lint and types: eslint with `--max-warnings 0` is clean, and the file has no type errors. These runs used this container, not your validation image or the 2-CPU/2-GiB setup from the issue. There are no previews because the change isn't visible to users. --- .../e2e/list-search-lazy-navigation.pw.ts | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/propr-ui/e2e/list-search-lazy-navigation.pw.ts b/propr-ui/e2e/list-search-lazy-navigation.pw.ts index 31593c727..50a5df760 100644 --- a/propr-ui/e2e/list-search-lazy-navigation.pw.ts +++ b/propr-ui/e2e/list-search-lazy-navigation.pw.ts @@ -82,8 +82,32 @@ async function holdSettingsChunk(page: Page) { return { wasRequested, release }; } -/** Stops the page's timers (the installed clock) from here until the test advances them. */ -const pauseTimers = async (page: Page) => page.clock.pauseAt(await page.evaluate(() => Date.now()) + 50); +const pageTime = (page: Page) => page.evaluate(() => Date.now()); + +/** + * Stops the page's timers (the installed clock) from here until the test advances them, and + * returns the page time it stopped at. + * + * `pauseAt` jumps the clock forward to its target, so a target taken from a sampled page time + * plus a margin lands in the past once command latency exceeds the margin. Instead the page's + * Date is pinned to the sampled time first. While pinned, the clock's time no longer follows + * real time (its timers still do), so `pauseAt` at that same time jumps by exactly 0 ms however + * late it arrives, fires nothing early, and cannot be in the past. Unpinning while paused then + * leaves Date stopped at that time. `stall` only exists to prove this with added latency. + */ +async function pauseTimers(page: Page, stall: () => Promise = async () => {}) { + const time = await pageTime(page); + await stall(); + await page.clock.setFixedTime(time); + await stall(); + await page.clock.pauseAt(time); + await stall(); + await page.clock.setSystemTime(time); + return time; +} +// Command latency forced between every pause step in the latency control: well over the +// 50 ms margin the old sampled-time pause used, and over the ~106 ms that broke it. +const COMMAND_LATENCY_MS = 250; const routePath = (page: Page) => page.evaluate(() => window.location.pathname); const routeSearch = (page: Page) => page.evaluate(() => window.location.search); @@ -198,5 +222,24 @@ for (const viewport of viewports) { await pauseTimers(page); await leaveForDelayedSettings(page, lists[2], chunk); }); + + test('control: pausing the clock survives command latency and keeps the typed search pending', async ({ page }) => { + const chunk = await holdSettingsChunk(page); + const list = lists[0]; + await visit(page, list.path); + const search = page.getByRole('textbox', { name: list.label }); + await expect(search).toBeVisible(); + + await press(search); + // Real time keeps running between every pause step, so the sampled time is long stale + // before the pause lands; a sampled-time-plus-50-ms target would be in the past here. + const pausedAt = await pauseTimers(page, () => page.waitForTimeout(COMMAND_LATENCY_MS)); + expect(await pageTime(page)).toBe(pausedAt); + await page.keyboard.type(QUERY); + await expect(search).toHaveValue(QUERY); + expect(await pageTime(page)).toBe(pausedAt); + expect(await routeSearch(page)).not.toContain('search='); + await leaveForDelayedSettings(page, list, chunk); + }); }); }