phlex-reactive 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ # System/browser test helpers for reactive components (issue #201). Loaded lazily
4
+ # by Phlex::Reactive::TestHelpers ONLY when Capybara is present (the pgbus/mcp
5
+ # optional-require precedent), so the gem never hard depends on Capybara and this
6
+ # module stays out of the request/unit path.
7
+ #
8
+ # Mix it into your system examples from rails_helper:
9
+ #
10
+ # RSpec.configure do |c|
11
+ # c.include Phlex::Reactive::TestHelpers::System, type: :system
12
+ # end
13
+ #
14
+ # It is built on the GLOBAL reactive-activity signal the client runtime exposes:
15
+ # a <html data-reactive-active> marker present while ANY reactive operation (a
16
+ # dispatch round trip OR a deferred render) is in flight, cleared when the whole
17
+ # layer settles. That is the clean primitive to wait on — instead of each spec
18
+ # scraping the union of per-root busy/pending/seed selectors, or holding a node
19
+ # that a morph detaches (StaleReferenceError).
20
+ #
21
+ # wait_for_reactive # block until the layer is idle
22
+ # expect(page).to have_reactive_value("total", "6") # a field, re-resolved each poll
23
+ # expect(page).to have_reactive_text("recap", "6 items") # a mirror node, re-resolved
24
+ #
25
+ # All three re-resolve the DOM every poll cycle (Capybara's waiting behavior), so
26
+ # a compute re-seed / morph / in-flight round trip that REPLACES the node after
27
+ # the triggering action returns can never surface a StaleReferenceError or read a
28
+ # transient blank — the matcher waits for the value to SETTLE.
29
+ module Phlex
30
+ module Reactive
31
+ module TestHelpers
32
+ module System
33
+ # The <html> marker the client sets while the reactive layer is busy. Kept
34
+ # in lockstep with reactive_controller.js's ACTIVE_ATTR — the ONE selector
35
+ # every wait keys off. A [data-reactive-active] presence check is the system
36
+ # twin of wait_for_turbo watching the Turbo progress bar.
37
+ ACTIVE_MARKER = "data-reactive-active"
38
+
39
+ # Block until the reactive layer is IDLE — every dispatch round trip and
40
+ # deferred render has settled and the <html data-reactive-active> marker is
41
+ # gone. The system-test twin of wait_for_turbo (which watches the Turbo
42
+ # progress bar, NOT a reactive morph/seed, so it can't cover this).
43
+ #
44
+ # Implemented as a Capybara WAITING assertion (have_no_css on the document
45
+ # element with the default max wait), so it re-checks the live DOM each poll
46
+ # and raises a readable Capybara::ElementNotFound-style error if the layer
47
+ # never settles inside `timeout` — never a bare sleep, never a stale read.
48
+ #
49
+ # `timeout:` overrides Capybara.default_max_wait_time for a slow operation
50
+ # (a deferred render behind a real job). Returns nil; call it as a barrier
51
+ # BEFORE asserting a settled value if you are not already using one of the
52
+ # waiting matchers below.
53
+ def wait_for_reactive(timeout: nil)
54
+ # Scope the check to <html> via the :xpath "/html" so the marker is read
55
+ # on the document element the client writes it to — not a descendant.
56
+ # assert_no_selector WAITS (retries) until the marker clears or the wait
57
+ # budget elapses; a persistent marker fails LOUDLY with Capybara's own
58
+ # timeout error rather than a silent pass. Called on `page` (the current
59
+ # session) so it works regardless of whether the example group mixed in
60
+ # Capybara::DSL.
61
+ page.assert_no_selector(:xpath, "/html[@#{ACTIVE_MARKER}]", **wait_option(timeout))
62
+ nil
63
+ end
64
+
65
+ # Assert (waiting) that the field with DOM id `id` has value `value`,
66
+ # RE-RESOLVING the field by its id on every poll and reading its live
67
+ # `.value` PROPERTY (issue #204) — NOT the value attribute. A
68
+ # reactive_compute reducer paints a computed output with `el.value = …`
69
+ # (the property); for a DISABLED / read-only output the value attribute
70
+ # never reflects that, so an attribute-based matcher reads "" and fails.
71
+ # Reading the property covers enabled AND disabled/computed fields — the
72
+ # exact case this matcher was built for — and keeps the morph-immunity
73
+ # (each poll re-finds the node by id, so a re-seed/morph that replaces the
74
+ # input can't surface a stale node or a transient blank).
75
+ #
76
+ # expect(page).to have_reactive_value("total", "6")
77
+ #
78
+ # `timeout:` (or `wait:`) overrides Capybara's default max wait. The
79
+ # `have_` prefix is Capybara-matcher convention (have_field/have_css), NOT
80
+ # a predicate — hence the PredicatePrefix disable, mirroring matchers.rb.
81
+ # rubocop:disable Naming/PredicatePrefix
82
+ def have_reactive_value(id, value, timeout: nil, wait: nil)
83
+ ReactiveValueMatcher.new(id, value, wait: timeout || wait)
84
+ end
85
+
86
+ # Assert (waiting) that the node with DOM id `id` has TEXT `value`,
87
+ # re-resolving by id each poll — the mirror/recap twin of
88
+ # have_reactive_value for a text sink (a reactive_compute `text:`/mirror
89
+ # target, a recap node) rather than a form field.
90
+ #
91
+ # expect(page).to have_reactive_text("recap", "6 items")
92
+ def have_reactive_text(id, value, **)
93
+ have_css("##{id}", text: value, **)
94
+ end
95
+ # rubocop:enable Naming/PredicatePrefix
96
+
97
+ # A waiting matcher that asserts a field's live `.value` PROPERTY (issue
98
+ # #204), read by id via evaluate_script so it sees a value a reducer set
99
+ # on a DISABLED output (where the value attribute stays blank). Polls until
100
+ # the property equals the expected value or the wait budget elapses,
101
+ # re-resolving the node by id every cycle (morph-immune). Supports negation
102
+ # (`not_to`) the Capybara way: the negative holds as soon as the property
103
+ # differs from the expected value (an absent field reads nil, which never
104
+ # equals a String expectation — so a missing field satisfies `not_to`).
105
+ #
106
+ # A plain class (not RSpec::Matchers.define) so it needs no RSpec at load
107
+ # time beyond the duck-typed matcher protocol RSpec calls (matches?,
108
+ # does_not_match?, failure_message*), keeping System loadable under Capybara
109
+ # alone. The expected value is stringified because a DOM `.value` is always
110
+ # a JS string on the wire (a numeric literal like 6 compares as "6").
111
+ class ReactiveValueMatcher
112
+ def initialize(id, value, wait: nil)
113
+ @id = id
114
+ @expected = value.to_s
115
+ @wait = wait
116
+ end
117
+
118
+ def matches?(page)
119
+ @page = page
120
+ poll_until { current_value == @expected }
121
+ end
122
+
123
+ # does_not_match? is RSpec's REQUIRED negated-matcher protocol method — its
124
+ # name is fixed by RSpec, not a predicate we get to rename.
125
+ # rubocop:disable Naming/PredicatePrefix
126
+ def does_not_match?(page)
127
+ @page = page
128
+ # The negative is satisfied the moment the property is NOT the expected
129
+ # value (or the field is absent → nil). poll_until returns true as soon
130
+ # as that holds, false if it stayed equal for the whole budget.
131
+ poll_until { current_value != @expected }
132
+ end
133
+ # rubocop:enable Naming/PredicatePrefix
134
+
135
+ def failure_message
136
+ "expected ##{@id} to have value #{@expected.inspect} (its .value property), " \
137
+ "but after waiting it was #{current_value.inspect}"
138
+ end
139
+
140
+ def failure_message_when_negated
141
+ "expected ##{@id} NOT to have value #{@expected.inspect} (its .value property), but it did"
142
+ end
143
+
144
+ private
145
+
146
+ # The field's live `.value` property, re-resolved each call. The
147
+ # identifier is matched by DOM id OR name (mirroring Capybara's
148
+ # have_field, which matches id/name/label) — reactive_field emits a
149
+ # `name`, not an `id`, so an id-only lookup would find nothing. Returns
150
+ # the String value, or nil when the field is absent (Playwright serializes
151
+ # a JS null as an empty Hash, so a non-String result is normalized to nil
152
+ # — the matcher then treats it as "not settled yet" and keeps polling
153
+ # rather than comparing a Hash to the expected String).
154
+ def current_value
155
+ raw = @page.evaluate_script(<<~JS)
156
+ (() => {
157
+ const k = #{@id.to_json}
158
+ const el = document.getElementById(k) || document.getElementsByName(k)[0]
159
+ return el ? el.value : null
160
+ })()
161
+ JS
162
+ raw.is_a?(::String) ? raw : nil
163
+ end
164
+
165
+ # Bounded poll on a JS-property condition Capybara can't express as a
166
+ # built-in waiting matcher. Uses the monotonic clock and the configured
167
+ # (or overridden) Capybara max wait — the same shape the system specs'
168
+ # hand-rolled wait_for used, now lifted into the helper.
169
+ def poll_until
170
+ deadline = now + (@wait || ::Capybara.default_max_wait_time)
171
+ loop do
172
+ return true if yield
173
+ return false if now >= deadline
174
+
175
+ sleep 0.05
176
+ end
177
+ end
178
+
179
+ def now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
180
+ end
181
+
182
+ private
183
+
184
+ # Fold an explicit `timeout:` into Capybara's `wait:` option, or omit it so
185
+ # Capybara's configured default applies. Kept tiny so every waiter shares
186
+ # one timeout convention.
187
+ def wait_option(timeout)
188
+ timeout.nil? ? {} : { wait: timeout }
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -314,3 +314,9 @@ end
314
314
  # them only when RSpec is present, so a Minitest app can mix in TestHelpers and
315
315
  # assert on Result predicates directly with no RSpec dependency.
316
316
  require "phlex/reactive/test_helpers/matchers" if defined?(RSpec::Matchers)
317
+
318
+ # The system/browser helpers (wait_for_reactive + the re-resolving value/text
319
+ # matchers, issue #201) are optional too: load them only when Capybara is present
320
+ # (a dev/test dependency), keeping them entirely out of the runtime path — the
321
+ # same optional-require gate as the matchers above and the engine below.
322
+ require "phlex/reactive/test_helpers/system" if defined?(Capybara)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.11.1"
5
+ VERSION = "0.11.3"
6
6
  end
7
7
  end
@@ -978,6 +978,11 @@ loader.ignore("#{lib}/generators")
978
978
  # Zeitwerk's naming — test_helpers.rb requires it explicitly (only when RSpec is
979
979
  # present), and the loader must ignore it.
980
980
  loader.ignore("#{lib}/phlex/reactive/test_helpers/matchers.rb")
981
+ # The system/browser test helpers (issue #201) are Capybara-only — a dev/test
982
+ # dependency, never a runtime one. test_helpers.rb requires this file explicitly
983
+ # only when Capybara is present, so the loader must ignore it (otherwise an
984
+ # eager-load in production would define browser helpers with no Capybara).
985
+ loader.ignore("#{lib}/phlex/reactive/test_helpers/system.rb")
981
986
  # The MCP diagnostic tool tree (issue #168) subclasses the OPTIONAL `mcp` gem's
982
987
  # constants (MCP::Tool) at class-definition time, so the whole mcp/ subdirectory
983
988
  # must stay out of the autoloader — Phlex::Reactive::MCP.load! requires it in
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 0.11.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -177,6 +177,7 @@ files:
177
177
  - lib/phlex/reactive/streamable.rb
178
178
  - lib/phlex/reactive/test_helpers.rb
179
179
  - lib/phlex/reactive/test_helpers/matchers.rb
180
+ - lib/phlex/reactive/test_helpers/system.rb
180
181
  - lib/phlex/reactive/version.rb
181
182
  - lib/tasks/phlex_reactive.rake
182
183
  homepage: https://github.com/mhenrixon/phlex-reactive