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 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", diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index 85e2f48f..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', () => { @@ -24,10 +24,12 @@ 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) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('article-save-button').isDisabled) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Jane')) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => !el('article-save-button').isDisabled, + ) expect(el('article-dirty-notice').exists).toBe(true) }) @@ -46,10 +48,12 @@ 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) - el('[role="dialog"] button[class]').click() - - waitFor(() => el('tag-badge-TypeScript').exists) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('TypeScript')) + 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 9665e7aa..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', () => { @@ -21,10 +21,12 @@ 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) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('author-select-save-button').isDisabled) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Bob')) + 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\`)`