ReviewOS

stacks/very-happy-dom

No web-first assertions: expect(locator).toBeVisible() and the retrying matchers have no equivalent

#1603
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Problem

Playwright's assertions are the bulk of a Playwright spec, and none of them has an equivalent here. Across wildloop's 15 .pw.ts specs:

matcheruses
expect(...) total323
toBeVisible111
toContainText28
toHaveCount26
toHaveURL18
toHaveAttribute18
toHaveText14
toHaveValue10
expect.poll10

bun:test supplies none of them, and does not supply the retrying primitive either:

import { expect } from 'bun:test'
typeof (expect as any).extend  // 'function'
typeof (expect as any).poll    // 'undefined'  (bun 1.4.3)

So today the closest a ported spec can get is:

// Playwright
await expect(page.getByRole('alert')).toContainText('Saved')

// here
expect(await page.getByRole('alert').textContent()).toContain('Saved')

Why it matters

The rewrite above is not just uglier, it asserts something weaker. A web-first matcher retries until the expectation holds or the timeout expires; the await-then-assert form samples once. Anything that appears after a fetch resolves, a microtask drains, or a custom element upgrades is a coin flip — and it fails in the direction that hurts, because a race that usually passes locally is exactly the test that goes flaky in CI.

The failure messages differ too. toBeVisible() reports what it found and how long it waited. expect(await locator.isVisible()).toBe(true) reports false !== true, with nothing about which element or why.

expect.extend is available, so these can be registered from here and bun:test will pick them up — no change to anyone's test runner.

Proposed scope

A very-happy-dom/matchers entry point registering the locator matchers through expect.extend, each retrying until the default timeout:

  • toBeVisible / toBeHidden
  • toHaveText / toContainText (string or RegExp, whitespace-normalised, array form for multiple matches)
  • toHaveCount
  • toHaveValue
  • toHaveAttribute / toHaveClass
  • toBeEnabled / toBeDisabled / toBeChecked / toBeEditable / toBeFocused
  • toHaveURL / toHaveTitle on the page

Each has a one-shot equivalent already on Locator, so the matcher is a retry wrapper plus a message. The retry substrate is the dependency — see the locator auto-waiting issue; a matcher that samples once inherits the problem it is meant to fix.

Failure messages should name the locator (Locator._description already carries it) and the last value seen.

Acceptance

  • await expect(page.getByRole('button', { name: 'Save' })).toBeVisible() passes against a visible button and fails with a message naming the locator against a hidden one.
  • A matcher passes for content that only appears after an awaited fetch, proving it retried rather than sampled.
  • A mutation test: breaking the underlying query makes the matcher fail, so the matcher is not vacuously true.
  • bun:test's own matchers keep working alongside them.

Found while assessing whether wildloop's Playwright specs could port here: every one of the 15 depends on these, so this is the single largest blocker.

Sign in to comment on this issue.