Skip to content

[Bug] Text assertions don't normalize whitespace (parity gap with upstream toContainText/toHaveText) (triggered by Folio) #124

Description

@padamson

Summary

expect(locator).to_contain_text(...) and to_have_text(...) compare raw inner_text() output with only outer whitespace trimmed, then do a plain substring/equality check. Upstream Playwright normalizes internal whitespace (collapsing runs of whitespace, including newlines, into single spaces) before matching toContainText/toHaveText, so elements that render text across multiple lines still match a single-line expectation. playwright-rust's assertions don't do this, causing spurious failures (and a full retry-timeout wait) on any element whose text content spans multiple lines or has irregular internal spacing.

Discovered in: Folio (github-padamson/folio), browser preview E2E testing.

Current Behavior

In crates/playwright/src/assertions.rs on main (as of 2026-07-18), to_contain_text (lines 302-340) does:

pub async fn to_contain_text(self, expected: &str) -> Result<()> {
    ...
    loop {
        let actual_text = self.locator.inner_text().await?;
        let actual = actual_text.trim();

        let matches = if self.negate {
            !actual.contains(expected)
        } else {
            actual.contains(expected)
        };
        ...
    }
}

to_have_text (lines 210-250) does the same thing with equality instead of contains, and additionally only trims the expected string's outer whitespace (expected.trim()), not its internal whitespace. Neither method collapses internal whitespace/newlines in actual or expected.

Code that triggered this (from Folio's crates/folio-cli/tests/browser_preview_test.rs):

let progress = page.locator(".workflow-progress").await?;
expect(&progress).to_contain_text("Scan → Group → Name → Review → Copy").await?;

Error/Output:

Expected element '.workflow-progress' to contain text 'Scan → Group → Name → Review → Copy', but had 'Scan\n→\nGroup\n→\nName\n→\nReview\n→\nCopy' after 5s.

The element's text content is semantically identical once whitespace is normalized, but the assertion burns the full 5s retry/timeout window before failing with a confusing error.

Expected Behavior

Whitespace (newlines, tabs, runs of spaces) should be collapsed to single spaces in both the actual DOM text and the expected string before the string-argument comparison in to_have_text and to_contain_text, matching upstream Playwright's documented behavior.

Upstream playwright behavior (JS):

// From https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-contain-text
// "This method also performs whitespace normalization."
await expect(page.locator('.workflow-progress'))
  .toContainText('Scan → Group → Name → Review → Copy');
// Passes even if the DOM text is "Scan\n→\nGroup\n→\nName\n→\nReview\n→\nCopy"

Note: the regex variants (to_have_text_regex, to_contain_text_regex) should not be changed — upstream matches regex patterns against the raw (non-normalized) text, so this normalization should only apply to the plain-string overloads.

Impact on Folio

  • Folio Feature Affected: Browser preview E2E testing (crates/folio-cli/tests/browser_preview_test.rs)
  • Blocking: No — a workaround is in place.
  • Current Workaround: Fetch text_content() manually, normalize with .split_whitespace().collect::<Vec<_>>().join(" "), then assert .contains(...) against the normalized string instead of calling expect(locator).to_contain_text(...) directly.

Proposed Solution

Add a small whitespace-normalization helper (collapse consecutive whitespace to a single space, trim ends) and apply it to both the actual text pulled from the DOM and the expected argument in to_have_text and to_contain_text (string-argument variants only; leave to_have_text_regex/to_contain_text_regex matching raw text). Add a test using a fixture element with multi-line/irregularly-spaced text content to lock in the behavior and prevent regression.

Ideal Rust API (internal change, no public API change needed):

fn normalize_whitespace(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

pub async fn to_contain_text(self, expected: &str) -> Result<()> {
    let expected = normalize_whitespace(expected);
    ...
    let actual_text = self.locator.inner_text().await?;
    let actual = normalize_whitespace(&actual_text);
    let matches = if self.negate { !actual.contains(&expected) } else { actual.contains(&expected) };
    ...
}

Additional Context

  • playwright-rust Version: main branch (git dependency, 0.15.0-unreleased line per Folio's Cargo.toml comment), fetched/verified 2026-07-18
  • Related Issues: none found (searched for "whitespace", "normalize", "to_contain_text", "to_have_text", "toContainText", "toHaveText" — closed issue Missing locator assertions: to_have_attribute, to_have_class, to_have_css, to_have_count #58 covers unrelated missing assertions: to_have_attribute, to_have_class, to_have_css, to_have_count)

Folio Context

Discovered in: Folio's browser preview E2E test suite (crates/folio-cli/tests/browser_preview_test.rs), while asserting on .workflow-progress text content rendered across multiple lines.


This issue was discovered during active development of Folio, a family media management system serving as a proving ground for playwright-rust.

Labels: folio-driven, bug

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfolio-drivenIssues discovered during Folio (github-padamson/folio) development

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions