Skip to content

Commit 2f5fe18

Browse files
committed
chore: release 0.4.0
1 parent d3f6c84 commit 2f5fe18

12 files changed

Lines changed: 158 additions & 29 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.0] - 2026-02-06
11+
12+
### Added
13+
14+
- **Auto-waiting matchers:** `have_text`, `have_content`, and `have_current_path` now automatically retry until the expected condition is met or the timeout expires, eliminating flaky tests caused by page transitions and async rendering
15+
- `have_current_path` matcher for asserting the current URL path with auto-waiting (supports exact strings and regexps)
16+
- `E2E.wait_until` helper method for custom waiting logic in tests
17+
- `wait_timeout` configuration option (default: 5 seconds) to control how long matchers wait before failing
18+
- `text` method on page/session for reading visible text content of the page body
19+
20+
### Changed
21+
22+
- `fill_in` now matches fields by label, placeholder, id, and name (Capybara-like behavior) instead of only CSS selectors, with fallback to direct CSS selector
23+
1024
## [0.3.2] - 2026-02-05
1125

1226
### Fixed

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
e2e (0.3.2)
4+
e2e (0.4.0)
55
playwright-ruby-client (>= 1.40.0)
66
rack
77
rackup
@@ -148,7 +148,7 @@ CHECKSUMS
148148
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
149149
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
150150
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
151-
e2e (0.3.2)
151+
e2e (0.4.0)
152152
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
153153
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
154154
irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806

README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ RSpec.describe "User Login", type: :e2e do
8585
fill_in "Password", with: "password"
8686
click_button "Sign In"
8787
88-
expect(page.body).to include("Welcome, User!")
88+
expect(page).to have_current_path("/dashboard")
89+
expect(page).to have_content("Welcome, User!")
8990
end
9091
end
9192
```
@@ -159,7 +160,8 @@ click_button "Submit"
159160
click_link "Read more"
160161
click "#nav-menu" # CSS selector
161162
162-
fill_in "Email", with: "test@example.com"
163+
fill_in "Email", with: "test@example.com" # Matches by label, placeholder, id, or name
164+
fill_in "#email", with: "test@example.com" # CSS selector fallback
163165
check "I agree"
164166
uncheck "Subscribe"
165167
attach_file "#upload", "path/to/file.png"
@@ -175,11 +177,18 @@ find("button", text: "Save") # Filter by text
175177

176178
#### Assertions & Matchers
177179

180+
All text and path matchers **automatically wait** for the expected condition to be met (up to `wait_timeout` seconds), making your tests resilient to page transitions and async rendering.
181+
178182
```ruby
179-
# Check for content
180-
expect(page.body).to include("Success")
181-
expect(find(".alert")).to have_text("Success")
182-
expect(find(".alert")).to have_content("Success") # Alias
183+
# Check for content (auto-waiting)
184+
expect(page).to have_text("Success")
185+
expect(page).to have_content("Success") # Alias for have_text
186+
expect(find(".alert")).to have_text("Success") # Works on elements too
187+
expect(page).to have_text(/welcome/i) # Regexp support
188+
189+
# Check current path (auto-waiting)
190+
expect(page).to have_current_path("/dashboard")
191+
expect(page).to have_current_path(/\/users\/\d+/) # Regexp support
183192
184193
# Check for classes
185194
expect(find(".alert")).to have_class("success")
@@ -206,6 +215,26 @@ evaluate("document.title") # Execute JS
206215
save_screenshot("tmp/screen.png")
207216
```
208217

218+
### Auto-Waiting
219+
220+
The `have_text`, `have_content`, and `have_current_path` matchers automatically retry until the condition is met or the configured `wait_timeout` expires (default: 5 seconds). This eliminates flaky tests caused by page navigations, redirects, and async rendering.
221+
222+
```ruby
223+
click_button "Submit"
224+
# No need for sleep or manual waiting — the matcher will poll until
225+
# the page transitions and the expected content appears
226+
expect(page).to have_current_path("/success")
227+
expect(page).to have_content("Your order has been placed")
228+
```
229+
230+
For custom waiting logic, use `E2E.wait_until`:
231+
232+
```ruby
233+
E2E.wait_until(timeout: 10) do
234+
page.current_url.include?("/ready")
235+
end
236+
```
237+
209238
### 🔓 Native Access (The Escape Hatch)
210239

211240
We believe you shouldn't be limited by the wrapper. You can access the underlying `Playwright::Page` object at any time using `.native`.
@@ -265,6 +294,7 @@ E2E.configure do |config|
265294
config.browser_type = :chromium # Options: :chromium (default), :firefox, :webkit
266295
config.headless = ENV.fetch("HEADLESS", "true") == "true"
267296
config.app = Rails.application # Automatic Rack booting
297+
config.wait_timeout = 5 # Seconds to wait in auto-waiting matchers (default: 5)
268298
end
269299
```
270300

gemfiles/rails_7.0.gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: ..
33
specs:
4-
e2e (0.3.2)
4+
e2e (0.4.0)
55
playwright-ruby-client (>= 1.40.0)
66
rack
77
rackup
@@ -335,7 +335,7 @@ CHECKSUMS
335335
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
336336
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
337337
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
338-
e2e (0.3.2)
338+
e2e (0.4.0)
339339
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
340340
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
341341
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11

gemfiles/rails_7.1.gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: ..
33
specs:
4-
e2e (0.3.2)
4+
e2e (0.4.0)
55
playwright-ruby-client (>= 1.40.0)
66
rack
77
rackup
@@ -349,7 +349,7 @@ CHECKSUMS
349349
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
350350
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
351351
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
352-
e2e (0.3.2)
352+
e2e (0.4.0)
353353
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
354354
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
355355
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11

gemfiles/rails_7.2.gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: ..
33
specs:
4-
e2e (0.3.2)
4+
e2e (0.4.0)
55
playwright-ruby-client (>= 1.40.0)
66
rack
77
rackup
@@ -343,7 +343,7 @@ CHECKSUMS
343343
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
344344
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
345345
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
346-
e2e (0.3.2)
346+
e2e (0.4.0)
347347
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
348348
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
349349
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11

gemfiles/rails_8.0.gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: ..
33
specs:
4-
e2e (0.3.2)
4+
e2e (0.4.0)
55
playwright-ruby-client (>= 1.40.0)
66
rack
77
rackup
@@ -339,7 +339,7 @@ CHECKSUMS
339339
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
340340
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
341341
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
342-
e2e (0.3.2)
342+
e2e (0.4.0)
343343
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
344344
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
345345
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11

lib/e2e.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,30 @@ def server
3939
end
4040
end
4141

42+
# Retry a block until it returns truthy or timeout is reached
43+
def self.wait_until(timeout: config.wait_timeout, interval: 0.05)
44+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
45+
loop do
46+
result = yield
47+
return result if result
48+
49+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
50+
return false
51+
end
52+
53+
sleep interval
54+
end
55+
end
56+
4257
class Config
43-
attr_accessor :driver, :headless, :app, :browser_type
58+
attr_accessor :driver, :headless, :app, :browser_type, :wait_timeout
4459

4560
def initialize
4661
@driver = :playwright
4762
@browser_type = :chromium
4863
@headless = ENV.fetch("HEADLESS", "true") == "true"
4964
@app = nil
65+
@wait_timeout = 5
5066
end
5167
end
5268
end

lib/e2e/drivers/playwright.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,21 @@ def click_link(value, **options)
5959
end
6060

6161
def fill_in(selector, with:)
62-
@page.fill(selector, with)
62+
# Try to match Capybara's behavior: Label, Placeholder, ID, Name
63+
chain = @page.get_by_label(selector).or(@page.get_by_placeholder(selector))
64+
65+
# Only add ID/Name matching if the selector doesn't contain spaces (valid CSS ID/Name assumption-ish)
66+
if selector.match?(/^[a-zA-Z0-9_-]+$/)
67+
chain = chain.or(@page.locator("##{selector}"))
68+
.or(@page.locator("[name='#{selector}']"))
69+
end
70+
71+
begin
72+
chain.first.fill(with)
73+
rescue
74+
# Fallback to treating it as a direct CSS selector if the above failed
75+
@page.fill(selector, with)
76+
end
6377
end
6478

6579
def check(selector)
@@ -78,6 +92,11 @@ def body
7892
@page.content
7993
end
8094

95+
# Required for have_content matcher on page object
96+
def text
97+
@page.inner_text("body")
98+
end
99+
81100
def evaluate(script)
82101
@page.evaluate(script)
83102
end

lib/e2e/matchers.rb

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,75 @@
1616
end
1717

1818
RSpec::Matchers.define :have_text do |expected_text|
19-
match do |element|
20-
if expected_text.is_a?(Regexp)
21-
element.text.match?(expected_text)
22-
else
23-
element.text.include?(expected_text)
19+
match do |actual|
20+
@actual_text = nil
21+
22+
E2E.wait_until do
23+
@actual_text = actual.text
24+
if expected_text.is_a?(Regexp)
25+
@actual_text.match?(expected_text)
26+
else
27+
@actual_text.include?(expected_text)
28+
end
2429
end
2530
end
2631

27-
failure_message do |element|
28-
"expected element to have text '#{expected_text}', but it had '#{element.text}'"
32+
match_when_negated do |actual|
33+
E2E.wait_until do
34+
@actual_text = actual.text
35+
if expected_text.is_a?(Regexp)
36+
!@actual_text.match?(expected_text)
37+
else
38+
!@actual_text.include?(expected_text)
39+
end
40+
end
2941
end
3042

31-
failure_message_when_negated do |element|
32-
"expected element not to have text '#{expected_text}', but it did"
43+
failure_message do
44+
"expected to find text #{expected_text.inspect} in #{@actual_text.inspect}"
45+
end
46+
47+
failure_message_when_negated do
48+
"expected not to find text #{expected_text.inspect} in #{@actual_text.inspect}"
3349
end
3450
end
3551

3652
RSpec::Matchers.alias_matcher :have_content, :have_text
3753

54+
RSpec::Matchers.define :have_current_path do |expected_path|
55+
match do |session|
56+
@actual_path = nil
57+
58+
E2E.wait_until do
59+
@actual_path = URI.parse(session.current_url).path
60+
if expected_path.is_a?(Regexp)
61+
@actual_path.match?(expected_path)
62+
else
63+
@actual_path == expected_path
64+
end
65+
end
66+
end
67+
68+
match_when_negated do |session|
69+
E2E.wait_until do
70+
@actual_path = URI.parse(session.current_url).path
71+
if expected_path.is_a?(Regexp)
72+
!@actual_path.match?(expected_path)
73+
else
74+
@actual_path != expected_path
75+
end
76+
end
77+
end
78+
79+
failure_message do
80+
"expected current path to be #{expected_path.inspect}, but was #{@actual_path.inspect}"
81+
end
82+
83+
failure_message_when_negated do
84+
"expected current path not to be #{expected_path.inspect}, but it was"
85+
end
86+
end
87+
3888
RSpec::Matchers.define :have_value do |expected_value|
3989
match do |element|
4090
element.value == expected_value

0 commit comments

Comments
 (0)