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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +114 -7
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/test_helpers/system.rb +193 -0
- data/lib/phlex/reactive/test_helpers.rb +6 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +5 -0
- metadata +2 -1
|
@@ -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)
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -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.
|
|
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
|