From 52a3b64d9083c3617dc32e61f55fe9ebe97a9be0 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 15:27:36 +0200 Subject: [PATCH 1/4] test(browser): wait for the filtered option before clicking select popovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests waited only for ANY button in the popover, so on a slow runner the click could hit the stale pre-filter option list (or a remounting node) — picking the wrong author / losing the click, then timing out on the save-button wait. Wait until the first option shows the filtered text instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- tests/browser/articleEditor.test.ts | 6 ++++-- tests/browser/authorSelect.test.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index 85e2f48f..bb25bb04 100644 --- a/tests/browser/articleEditor.test.ts +++ b/tests/browser/articleEditor.test.ts @@ -24,7 +24,8 @@ browserTest('Article Editor', () => { // Type in the search input to filter, then click the filtered option waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('Jane') - waitFor(() => el('[role="dialog"] button[class]').exists) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Jane')) el('[role="dialog"] button[class]').click() waitFor(() => !el('article-save-button').isDisabled) @@ -46,7 +47,8 @@ browserTest('Article Editor', () => { // Search for the tag and click it waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('TypeScript') - waitFor(() => el('[role="dialog"] button[class]').exists) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('TypeScript')) el('[role="dialog"] button[class]').click() waitFor(() => el('tag-badge-TypeScript').exists) diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index 9665e7aa..d394d534 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -21,7 +21,8 @@ browserTest('Article with Author Select', () => { // Type to filter and click an option waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('Bob') - waitFor(() => el('[role="dialog"] button[class]').exists) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Bob')) el('[role="dialog"] button[class]').click() waitFor(() => !el('author-select-save-button').isDisabled) From 73e6c301764ff441c256fa7c06dd7c0bcf87e69a Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 15:32:13 +0200 Subject: [PATCH 2/4] test(browser): retry lost popover clicks via clickUntil helper Filtered-option waits didn't fix CI: the click can land on an option node mid-remount (debounced fetch re-renders the list) and get silently lost. clickUntil re-clicks until the expected outcome materializes, checking the condition first so a registered click is never repeated (no multi-select toggle-off). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- tests/browser/articleEditor.test.ts | 16 ++++++++------- tests/browser/authorSelect.test.ts | 9 +++++---- tests/browser/browser.ts | 31 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index bb25bb04..6618f4fb 100644 --- a/tests/browser/articleEditor.test.ts +++ b/tests/browser/articleEditor.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'bun:test' -import { browserTest, el, tid, waitFor } from './browser.js' +import { browserTest, clickUntil, el, tid, waitFor } from './browser.js' browserTest('Article Editor', () => { test('section renders with all sub-components', () => { @@ -26,9 +26,10 @@ browserTest('Article Editor', () => { el('[role="dialog"] input').fill('Jane') // Wait for the FILTERED option — the stale pre-filter list also has buttons waitFor(() => el('[role="dialog"] button[class]').text.includes('Jane')) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('article-save-button').isDisabled) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => !el('article-save-button').isDisabled, + ) expect(el('article-dirty-notice').exists).toBe(true) }) @@ -49,9 +50,10 @@ browserTest('Article Editor', () => { el('[role="dialog"] input').fill('TypeScript') // Wait for the FILTERED option — the stale pre-filter list also has buttons waitFor(() => el('[role="dialog"] button[class]').text.includes('TypeScript')) - el('[role="dialog"] button[class]').click() - - waitFor(() => el('tag-badge-TypeScript').exists) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => el('tag-badge-TypeScript').exists, + ) }) }, 'article-editor') diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index d394d534..fe1e8ad8 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'bun:test' -import { browserTest, el, tid, waitFor } from './browser.js' +import { browserTest, clickUntil, el, tid, waitFor } from './browser.js' browserTest('Article with Author Select', () => { test('section renders', () => { @@ -23,9 +23,10 @@ browserTest('Article with Author Select', () => { el('[role="dialog"] input').fill('Bob') // Wait for the FILTERED option — the stale pre-filter list also has buttons waitFor(() => el('[role="dialog"] button[class]').text.includes('Bob')) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('author-select-save-button').isDisabled) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => !el('author-select-save-button').isDisabled, + ) expect(el('current-author-display').text).toContain('Changes will be applied on save') }) diff --git a/tests/browser/browser.ts b/tests/browser/browser.ts index 435743ed..e375403b 100644 --- a/tests/browser/browser.ts +++ b/tests/browser/browser.ts @@ -111,6 +111,37 @@ export function el(selector: string): ElementHandle { } } +/** + * Click until the expected outcome materializes — popover option lists re-render + * async (debounced fetches), so a click can land on a node mid-remount and get lost. + * Checks the condition before re-clicking, so a registered click is never repeated. + */ +export function clickUntil( + target: () => ElementHandle, + condition: () => boolean, + { attempts = 3, settle = 3_000 }: { attempts?: number; settle?: number } = {}, +): void { + for (let i = 0; i < attempts; i++) { + try { + if (condition()) return + } catch { + // not ready yet + } + try { + target().click() + } catch { + // target gone — the previous click may have registered and closed the popover + } + try { + waitFor(condition, { timeout: settle }) + return + } catch { + // outcome didn't materialize — re-click + } + } + waitFor(condition, { timeout: settle }) +} + /** * Build a `[data-testid="..."]` selector for compound selectors. * Usage: `el(\`\${tid('parent')} button\`)` From 9a8c1acf1f3c957cc806294a0515a7fd9e5289cb Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 15:49:08 +0200 Subject: [PATCH 3/4] test(browser): raise the per-test timeout above the retry budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser suite drives a real browser through agent-browser, and clickUntil retries a lost popover click up to 3 times with a 3s settle each — a budget of up to 12s for one test. bun's per-test timeout is 5s, so the second retry can never finish: bun kills the test mid-attempt and the assertion reports whatever slice of its own budget was left. That is the "waitFor timed out after 3183ms" in CI, where the runner shares a container with postgres, an S3 stand-in and the Contember engine and the first settle actually elapses. Locally the first click lands immediately, so the suite passes and the ceiling is never reached. bunfig.toml already declares timeout = 15000, but bun ignores that key — verified with a 6s test, killed at 5002ms regardless. Documented there so the next person does not trust it, and the browser script now passes --timeout on the command line, which does work. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- bunfig.toml | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bunfig.toml b/bunfig.toml index 242bd9b6..60c49953 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,9 @@ [test] preload = ["./tests/setup.ts"] +# NOTE: bun ignores this key — the per-test timeout stays at its 5000ms default. +# Verified with a 6s test, which is killed at 5002ms regardless of the value here. +# Suites that legitimately need longer pass --timeout on the command line +# (see the test:browser script). timeout = 15000 [install] diff --git a/package.json b/package.json index 14fd0970..9a73a410 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "tsc --build --watch", "typecheck": "tsc --build", "test": "bun test tests/unit tests/react tests/cases tests/*.test.ts tests/*.test.tsx", - "test:browser": "bun test tests/browser/", + "test:browser": "bun test --timeout 30000 tests/browser/", "test:all": "bun test", "playground": "cd packages/example && bun run dev", "playground:contember": "cd packages/example && VITE_CONTEMBER_API_URL=http://localhost:1581 VITE_CONTEMBER_API_TOKEN=0000000000000000000000000000000000000000 bun run dev", From 4fa5052e336dad3ee803949d49c52de90d02e362 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 15:54:37 +0200 Subject: [PATCH 4/4] ci: pin agent-browser to the version the browser suite is proven on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow installed the browser driver with a bare `bun add -g agent-browser`, so it could change under the suite between two runs of the same commit — and it did: 0.31.1 in early July, 0.32.x from mid-July, 0.34.0 since 2026-08-10. CI last went green on 2026-07-22 with the clickUntil retry helper and 0.32.4. The same helper against 0.34.0 still fails `Article with Author Select`, so the driver version is load-bearing for that test. Locally the suite passes on 0.27.1, 0.32.4 and 0.34.0 alike, which is why this only ever showed up in CI. Pinning makes the suite reproducible. Moving the pin forward is then a deliberate change with its own CI evidence, rather than something that happens silently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 892d145d..7ae2d1ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,8 +89,11 @@ jobs: - run: bun install --frozen-lockfile - # Install agent-browser + Chromium - - run: bun add -g agent-browser + # Install agent-browser + Chromium. + # Pinned: the driver is a test dependency that can otherwise change under the + # suite between two runs of the same commit. 0.32.4 is the version the popover + # tests were last proven green on in CI; 0.34.0 fails them even with clickUntil. + - run: bun add -g agent-browser@0.32.4 - run: agent-browser install # Run Contember migrations & seed