Skip to content

test(webui): wait for the page's state in the isolated Chromium suite - #235

Merged
modacker merged 2 commits into
webuifrom
fix/isolated-chromium-timeout-budget
Oct 23, 2026
Merged

modacker merged 2 commits into
webuifrom
fix/isolated-chromium-timeout-budget

Conversation

@modacker

@modacker modacker commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

What broke

Mainline run 39879196689 (SHA 2bd9a2a1) went red on a test timeout, not on an assertion:

FAIL packages/webui/test/integration/isolated-chromium-two-pages.test.ts
  > the web tab's toolbar, on a real page > walks a real page's history and re-reads it
Error: Test timed out in 5000ms.
  ❯ packages/webui/test/integration/isolated-chromium-two-pages.test.ts:614:3

Test Files 1 failed | 212 passed (213), Tests 1 failed | 3296 passed (3297), Verification failed at: test:webui.

Re-running the same SHA passed. Nothing in the product had changed — the case failed on its own arithmetic.

The first version of this PR fixed that by turning the sleeps into waits, and the macOS CI run then failed two cases in the same file:

× drives, streams and inputs into two pages independently  5015ms
  Error: Test timed out in 5000ms.
× refuses a stale reference on every page, not only the launch page  3997ms
  AssertionError: expected 'alphastill works' to be 'still works'

Both are fixed here. Neither was the product changing.

Why the first version still failed

The budget was too small for a case that waits several times. A case waits for the state it asserts, and every one of those waits ends at WAIT_MS (3s). The case that waits most often waits seven times, so it can spend 21s in its waits alone — and the vitest budget was reached before the last wait could report why it failed. The failure was then Test timed out in 5000ms instead of the diagnostic the wait carries. Lowering WAIT_MS to 3s fixed the single-wait case and left the sum untouched.

A read-back waited for "the page changed", not for "this text arrived". The browser hands the keystrokes to the page on its own schedule, so a read taken right after the send can still see the field as it was — and a keystroke that is still on its way lands in the next case's text. The launch page is shared by the cases in that suite, so the case before it typed alpha, the next case typed still works, and the field came to hold alphastill works. The old code's 750ms sleep happened to absorb the late keystroke; a condition wait does not, because "the page changed" and "this text arrived" are different facts.

What this changes

Every sleep in the file became a wait for the state the case actually asserts:

const POLL_MS = 50;
const WAIT_MS = 3_000;

async function waitFor(condition, description): Promise<void> { /* ... */ }
  • The case that failed now waits for the title the page shows, for the generation the provider holds for it, for the page's place in the target list, and for the frame that has arrived.
  • waitFor never passes a condition that did not hold. A condition that throws is the page's own state (a loading page answers a read with an error), so it is asked again, and the last error is reported if the wait ends.
  • A wait that never comes true fails the case at the wait deadline, naming what it waited for — not by running a budget out.

Every read-back now waits for the value itself:

  • typeInto / personTypes wait for the field to hold the text that was typed, so a keystroke still on its way cannot land in the next case's text.
  • openPage waits for the title the new page shows, not only for the page to be listed.
  • The reload cases wait for the title as well as for the generation, because the generation moves when the navigation starts and the title is read again after it.
  • The two cases where the Agent writes the title wait for the page's own answer to the input first, so the two writes cannot land in the other order and leave the page showing the wrong title.

Two things came out of the same file:

  • A pre-existing race in the streaming case. Chrome renders the front page and throttles the ones behind it, so opening both streams at once let the second take the front and the first page produce no picture at all — the case passed on CI by luck and failed here every time. Each page's stream is now opened while that page is the one on screen. The assertion that the two frames stay apart is unchanged.
  • Chrome discovery now knows about macOS, the way the provider's own candidate list does. The file used to probe only Linux paths, so on a developer machine it skipped entirely and could not be checked there at all.

The budgets, and the arithmetic

Every case in this file drives a real browser, so the budget is not what judges a case — the wait deadline is. The budget is room for a runner that is several times slower than the machine the numbers were measured on, so that a slow machine cannot report a red that says nothing about the product.

The budget therefore has to cover a case's waits and the work around them:

WAIT_MS 3s — every wait ends here, and this is what catches a broken behaviour
largest sum of wait deadlines in the file 21s (the case that waits seven times)
slowest case, macOS CI, whole 213-file group running 5015ms
21s of waits + that work ≈ 26s
budget 60s — the number the repository already gives a gate that drives a real process (webui-dependency-check.test.ts)

60s leaves the round trips to a real browser about eight times the time the CI run measured them. 30s would have left 4s between a slow machine and a red that says nothing.

WAIT_MS is 20x below the budget, so a failure is always the condition's and never the budget's — including for a case that waits seven times, which is exactly what the macOS run got wrong.

How it was checked

The local runs that matter are the whole group, because CI runs the 213 files at once and a single file on an idle machine does not reproduce that.

  • The whole webui group, three times over (node scripts/run-vitest-suite.mjs webui, same file list and config as CI), with this file's per-case timings:
case run 1 run 2 run 3
drives, streams and inputs into two pages independently 194ms 179ms 250ms
advances one page's generation and leaves the other's alone 118ms 103ms 120ms
refuses a stale reference on every page, not only the launch page 189ms 190ms 229ms
answers open_tab with the identity of the page it created 132ms 132ms 102ms
delivers a frame to the page that asked for it and to no other 236ms 242ms 214ms
gives two tabs their own pictures, and neither receives the other's frames 1362ms 1235ms 1175ms
opens a page the same run can then navigate 998ms 902ms 1063ms
walks a real page's history and re-reads it 884ms 1453ms 1433ms
follows the page's generation, so a reference recorded before a move is refused 120ms 191ms 277ms
refuses a move the page's history cannot make, with the provider's own code 82ms 84ms 92ms
keeps the Agent's page and the person's page apart while both are in use 1132ms 982ms 890ms
delivers both on one page, and refuses a stale input rather than dropping it 160ms 235ms 172ms

12/12 pass in every run. The group reported 1 failed | 208 passed | 4 skipped (213) each time; the one failure is packages/webui/test/integration/agent-transcript.test.ts (the upstream turn-queue area), and it fails the same way on the pristine base 97e33fd8 with this branch's changes absent — it is not this PR.

  • This file running beside the whole group (the load the CI runner really has): 12/12 pass, the slowest case 1700ms.
  • pnpm --filter @mavis/webui typecheck:test passes.
  • The teeth, three regressions against the provider, all restored afterwards:
regression what the suite says
back/forward stays on the current history entry timed out waiting for the back navigation to land — 1 case fails, 11 pass
the page's generation never advances timed out waiting for the navigated page to advance its generation — 2 cases fail, 10 pass
the text input never lands timed out waiting for the field to hold "alpha" / "still works" / "one" / "person typed" / "both delivered" — 5 cases fail, 7 pass

Every one is a diagnostic at the wait deadline (3.1–3.6s), never vitest's Test timed out in 5000ms and never Test timed out in 60000ms.

Not addressed here

  • Two sibling files carry the same shape and were not touched: packages/webui/test/integration/isolated-chromium-screencast.test.ts has no explicit budget and its "keeps delivering frames" case sleeps 3000+500ms — 3500ms of 5000ms, the closest match to this one — and packages/webui/test/integration/browser-use-real-input.test.ts sleeps 750ms per navigation with no budget (a lower risk). Both also discover Chrome through the Linux-only path list, so they skip on a developer machine.
  • The race between the wait and the action it follows is still a race, in the ordinary sense: the condition says the state has been reached, and the assertion then reads it. A swap in that window is not detected.
  • The file needed no registration change: it is already in test/vitest-suites.json and release/public-source.json.

CI status of this branch

job this branch (f3d7eab7) mainline at the same base (97e33fd8)
verify (macos-latest) pass — this file runs and passes: ✓ isolated-chromium-two-pages.test.ts (12 tests) 36703ms, group 209 passed | 4 skipped (213) fail — agent-transcript.test.ts (the upstream turn-queue case); this file is skipped there, because the macOS Chrome discovery is only in this branch
verify (windows-latest) pass pass
verify (ubuntu-latest) fail — the Playwright browser suite: plugin-catalogue-layout.spec.mjs ×4, rail-pin.spec.mjs ×2, visual-baselines.spec.mjs ×8 (toHaveScreenshot mismatches). This file's 12 tests pass in the same job: ✓ isolated-chromium-two-pages.test.ts (12 tests) 15394ms fail — the same browser-suite failures

The ubuntu red is the mainline red: the same specs fail the same way on 97e33fd8 with this branch's changes absent. Nothing in it touches the isolated Chromium suite.

probe added 2 commits October 23, 2026 11:32
The suite slept 750ms after every navigation, every frame and every
repaint. The case that walks a page's history spent five of those sleeps
— 3750ms of its 4412ms — asleep, so it blew through the 5000ms budget
on a runner a little slower than the one it was measured on. Nothing in
the product had changed: the case failed on mainline on its own
arithmetic.

Every sleep is now a wait for the state the case asserts: the title the
page shows, the generation the provider holds for it, the page's place
in the target list, the frame that has arrived. A wait that never comes
true fails the case at its own deadline with the state it waited for
named, so the cases fail faster and say more than they did — the sleeps
only ever guessed, and several of them guessed about something the
provider already reports exactly.

The streaming case opens each page's stream while that page is the one
on screen, because Chrome renders the front page and throttles the ones
behind it. Before, the second stream took the front and the first page
produced no picture at all: the case passed on CI by luck and failed
here every time.

The four cases that drive a real browser and measured over 700ms are
given a 30s budget, the number the repository's other real-browser
suites use (browser-panel-live-navigation and
workspace-browser-tab-acceptance; webui-dependency-check uses 60s for
its own gate). The eight that measured under 250ms keep the default
budget, so a case that suddenly takes seconds still says something. The
teeth are in the waits: a broken navigation fails at the 3s wait
deadline with the state it waited for named, not by running a budget
out.

Checked against a real Chromium: the case that failed on mainline went
from 4412ms (4503ms here) to 1075ms, and the file from 39910ms to about
5200ms. Breaking the provider's back/forward on purpose fails the case
in 3767ms with "timed out waiting for the back navigation to land";
breaking its generation counter fails two more, at 3851ms and 4146ms.

The suite's Chrome discovery now knows about macOS, the way the
provider's own candidate list does, so the suite runs — and can be
checked — on a developer machine at all.
The macOS CI run failed two cases in this file, and in neither of them
was the product what changed: one case ran into the 5000ms budget, and
one read a field that still held the previous case's text.

The budget was the first problem. A case waits for the state it asserts,
and each of those waits ends at WAIT_MS — three seconds — so a case that
waits seven times can spend 21s in its waits alone, and the vitest
budget was reached before the last wait could report why it failed. The
failure was then "Test timed out in 5000ms" instead of the diagnostic
the wait carries.

Every case in this file drives a real browser, so the budget is not
what judges a case — the wait deadline is. The budget is now 60s for
every case: the largest sum of wait deadlines in this file is 21s, the
macOS run measured the slowest case at 5015ms, and 21s of waits plus
that work is about 26s, so 60s leaves the round trips to a real browser
about eight times the time the CI run measured them. 60s is the number
the repository already gives a gate that drives a real process.

The second problem was the read-back. A wait for "the page changed" is
not a wait for "this text arrived": the browser hands the keystrokes to
the page on its own schedule, so a read taken right after the send can
still see the field as it was, and a keystroke that is still on its way
lands in the next case's text — which is how a field came to hold
"alphastill works". Every read-back now waits for the value itself: the
field holds the text that was typed, the title is the one the page
shows, and the page's own answer to the input is waited for before
another write is allowed to land on top of it.

Checked against a real Chromium, the whole webui group at once, three
times over: the twelve cases pass every time, the slowest at 1453ms,
and the same file running beside the whole group passes with the
slowest at 1700ms. The teeth are still there: breaking the provider's
back/forward fails the case with "timed out waiting for the back
navigation to land", breaking its generation counter fails two more
with "timed out waiting for the navigated page to advance its
generation", and breaking the input itself fails five with "timed out
waiting for the field to hold ..." — every one of them a diagnostic at
the wait deadline, never a budget timeout.
@modacker
modacker force-pushed the fix/isolated-chromium-timeout-budget branch from a40ed6b to f3d7eab Compare October 23, 2026 03:45
@modacker modacker closed this Oct 23, 2026
@modacker modacker reopened this Oct 23, 2026
@modacker
modacker merged commit e468be0 into webui Oct 23, 2026
14 of 16 checks passed
@modacker
modacker deleted the fix/isolated-chromium-timeout-budget branch October 23, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant