ruact 0.0.7 → 0.0.8
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 +24 -0
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +9 -0
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +14 -2
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "ruact/testing"
|
|
5
|
+
|
|
6
|
+
# Story 15.4 (FR108) — end-to-end specs for the PUBLIC render-assertion matcher
|
|
7
|
+
# `have_ruact_component`. Every example asserts against a REAL Flight wire body
|
|
8
|
+
# produced by `Ruact::RenderPipeline` (the same serializer the controller runs),
|
|
9
|
+
# not a hand-authored fixture — so import rows, `$L<id>` references, and the
|
|
10
|
+
# serialized props hash are exactly what a host would receive.
|
|
11
|
+
module Ruact
|
|
12
|
+
RSpec.describe "have_ruact_component matcher", :story_15_4 do
|
|
13
|
+
let(:manifest) do
|
|
14
|
+
ClientManifest.from_hash({
|
|
15
|
+
"LikeButton" => {
|
|
16
|
+
"id" => "/assets/LikeButton-abc.js",
|
|
17
|
+
"name" => "LikeButton",
|
|
18
|
+
"chunks" => ["/assets/LikeButton-abc.js"]
|
|
19
|
+
},
|
|
20
|
+
"PostCard" => {
|
|
21
|
+
"id" => "/components/PostCard.jsx",
|
|
22
|
+
"name" => "PostCard",
|
|
23
|
+
"chunks" => ["/components/PostCard.jsx"]
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
let(:pipeline) { RenderPipeline.new(manifest) }
|
|
29
|
+
|
|
30
|
+
# Renders an ERB snippet to a REAL Flight wire string.
|
|
31
|
+
def render_wire(erb_source, **ivars)
|
|
32
|
+
ctx = Object.new
|
|
33
|
+
ivars.each { |k, v| ctx.instance_variable_set("@#{k}", v) }
|
|
34
|
+
pipeline.render({ erb: erb_source, binding: ctx.instance_eval { binding } }, mode: :string)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Wraps a wire in the REAL HTML shell the gem emits (`__FLIGHT_DATA` push),
|
|
38
|
+
# via the shipped `Ruact::ViewHelper#ruact_flight_data_script`.
|
|
39
|
+
def html_shell(wire)
|
|
40
|
+
emitter = Object.new.extend(Ruact::ViewHelper)
|
|
41
|
+
script = emitter.send(:ruact_flight_data_script, wire)
|
|
42
|
+
"<!DOCTYPE html><html><body><div id=\"root\"></div>#{script}</body></html>"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A minimal duck-typed response object exposing `.body` (+ optional
|
|
46
|
+
# `.content_type`), like an ActionDispatch/Rack response.
|
|
47
|
+
def response_double(body, content_type: nil)
|
|
48
|
+
Struct.new(:body, :content_type).new(body, content_type)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe "presence (raw text/x-component body)" do
|
|
52
|
+
it "passes when the named component was rendered" do
|
|
53
|
+
wire = render_wire("<LikeButton postId={42} />")
|
|
54
|
+
expect(wire).to have_ruact_component("LikeButton")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "resolves the name by module basename too (D3)" do
|
|
58
|
+
wire = render_wire("<PostCard title={\"Hi\"} />")
|
|
59
|
+
# Manifest module path is /components/PostCard.jsx → basename PostCard.
|
|
60
|
+
expect(wire).to have_ruact_component("PostCard")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "fails with a legible message naming what was found when absent" do
|
|
64
|
+
wire = render_wire("<LikeButton />")
|
|
65
|
+
expect do
|
|
66
|
+
expect(wire).to have_ruact_component("Missing")
|
|
67
|
+
end.to raise_error(RSpec::Expectations::ExpectationNotMetError,
|
|
68
|
+
/component named "Missing".*found.*LikeButton/m)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "supports negation for an absent component" do
|
|
72
|
+
wire = render_wire("<LikeButton />")
|
|
73
|
+
expect(wire).not_to have_ruact_component("Missing")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "negation fails legibly when the component IS present" do
|
|
77
|
+
wire = render_wire("<LikeButton />")
|
|
78
|
+
expect do
|
|
79
|
+
expect(wire).not_to have_ruact_component("LikeButton")
|
|
80
|
+
end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /NOT to have rendered.*"LikeButton"/m)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe ".with_props (serialized wire form, D4)" do
|
|
85
|
+
it "passes when props satisfy a hash_including matcher" do
|
|
86
|
+
wire = render_wire("<LikeButton postId={42} />")
|
|
87
|
+
expect(wire).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 42))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "passes with a bare key-presence include matcher" do
|
|
91
|
+
wire = render_wire("<LikeButton postId={42} />")
|
|
92
|
+
expect(wire).to have_ruact_component("LikeButton").with_props(including("postId"))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "fails with a legible message when props mismatch" do
|
|
96
|
+
wire = render_wire("<LikeButton postId={42} />")
|
|
97
|
+
expect do
|
|
98
|
+
expect(wire).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 99))
|
|
99
|
+
end.to raise_error(RSpec::Expectations::ExpectationNotMetError,
|
|
100
|
+
/props matching.*serialized wire form.*"postId".*42/m)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "supports negation with props" do
|
|
104
|
+
wire = render_wire("<LikeButton postId={42} />")
|
|
105
|
+
expect(wire).not_to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 99))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe "input shapes (D2)" do
|
|
110
|
+
it "accepts a response object exposing #body (raw wire)" do
|
|
111
|
+
wire = render_wire("<LikeButton postId={7} />")
|
|
112
|
+
response = response_double(wire, content_type: "text/x-component")
|
|
113
|
+
expect(response).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 7))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "extracts Flight from an HTML shell embedding __FLIGHT_DATA" do
|
|
117
|
+
wire = render_wire("<LikeButton postId={5} />")
|
|
118
|
+
html = html_shell(wire)
|
|
119
|
+
expect(html).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 5))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "ignores an unrelated earlier .push and anchors to __FLIGHT_DATA" do
|
|
123
|
+
wire = render_wire("<LikeButton postId={5} />")
|
|
124
|
+
shell = html_shell(wire)
|
|
125
|
+
# A realistic layout has other scripts BEFORE the ruact bootstrap.
|
|
126
|
+
noise = %(<script>window.dataLayer=window.dataLayer||[];dataLayer.push("not flight");</script>)
|
|
127
|
+
html = shell.sub("<div id=\"root\"></div>", "<div id=\"root\"></div>#{noise}")
|
|
128
|
+
expect(html).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 5))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "extracts Flight from an HTML-response object (content_type text/html)" do
|
|
132
|
+
wire = render_wire("<LikeButton postId={5} />")
|
|
133
|
+
response = response_double(html_shell(wire), content_type: "text/html; charset=utf-8")
|
|
134
|
+
expect(response).to have_ruact_component("LikeButton")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "raises a clear error on a plain-JSON function-call body" do
|
|
138
|
+
json = response_double(%({"id":1,"title":"Hi"}), content_type: "application/json")
|
|
139
|
+
expect do
|
|
140
|
+
expect(json).to have_ruact_component("PostCard")
|
|
141
|
+
end.to raise_error(Ruact::Testing::NotAFlightResponseError, /plain-JSON response.*JSON\.parse/m)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "raises a clear error on an empty (204) body" do
|
|
145
|
+
empty = response_double("", content_type: nil)
|
|
146
|
+
expect do
|
|
147
|
+
expect(empty).to have_ruact_component("PostCard")
|
|
148
|
+
end.to raise_error(Ruact::Testing::NotAFlightResponseError, /empty response body/m)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "raises a clear error when handed an HTML doc with no Flight payload" do
|
|
152
|
+
html = "<!DOCTYPE html><html><body><h1>Nothing here</h1></body></html>"
|
|
153
|
+
expect do
|
|
154
|
+
expect(html).to have_ruact_component("PostCard")
|
|
155
|
+
end.to raise_error(Ruact::Testing::NotAFlightResponseError, /no `__FLIGHT_DATA` payload/m)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe "escape round-trip through the HTML shell" do
|
|
160
|
+
it "recovers a payload containing quotes and interpolation-like sequences" do
|
|
161
|
+
# Props whose serialized form contains characters Ruby's #inspect
|
|
162
|
+
# escapes (`"`, `#{`, newlines) must survive extraction from the shell.
|
|
163
|
+
wire = render_wire("<LikeButton label={@label} />", label: %(a "quoted" \#{literal}\nvalue))
|
|
164
|
+
html = html_shell(wire)
|
|
165
|
+
expect(html).to have_ruact_component("LikeButton")
|
|
166
|
+
.with_props(a_hash_including("label" => a_string_matching(/quoted/)))
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "English"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
# Story 15.4 (FR108), AC4c + AC2 — the public test surface must not load in a
|
|
8
|
+
# production boot, and the promoted parser/diff must be the SINGLE shipped
|
|
9
|
+
# implementation the internal `Ruact::Spec` matchers share (wrap, not fork).
|
|
10
|
+
module Ruact
|
|
11
|
+
RSpec.describe "Ruact::Testing load boundary", :story_15_4 do
|
|
12
|
+
it "a bare `require \"ruact\"` does NOT define the public test surface" do
|
|
13
|
+
# Shell out to a pristine Ruby process so nothing this suite already
|
|
14
|
+
# loaded (spec/support requires `ruact/testing/*`) pollutes the check.
|
|
15
|
+
lib = File.expand_path("../../../lib", __dir__)
|
|
16
|
+
script = <<~RUBY
|
|
17
|
+
$LOAD_PATH.unshift(#{lib.inspect})
|
|
18
|
+
require "ruact"
|
|
19
|
+
defined = Ruact.const_defined?(:Testing, false)
|
|
20
|
+
# `have_ruact_component` must not be registered on the RSpec matcher surface either.
|
|
21
|
+
rspec_loaded = $LOADED_FEATURES.any? { |f| f.include?("rspec/expectations") }
|
|
22
|
+
print [defined, rspec_loaded].inspect
|
|
23
|
+
RUBY
|
|
24
|
+
|
|
25
|
+
output = `#{RbConfig.ruby} -e #{Shellwords.escape(script)} 2>&1`
|
|
26
|
+
expect($CHILD_STATUS).to be_success, "subprocess failed: #{output}"
|
|
27
|
+
expect(output).to eq("[false, false]"),
|
|
28
|
+
"expected `require \"ruact\"` to leave Ruact::Testing undefined and " \
|
|
29
|
+
"RSpec unloaded, got: #{output}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "shares ONE implementation with the internal matchers (no fork)" do
|
|
33
|
+
require "ruact/testing"
|
|
34
|
+
require_relative "../../support/flight_wire_parser"
|
|
35
|
+
require_relative "../../support/matchers/flight_fixture_matcher"
|
|
36
|
+
|
|
37
|
+
expect(Ruact::Spec::FlightWireParser).to equal(Ruact::Testing::FlightWireParser)
|
|
38
|
+
expect(Ruact::Spec::FlightStructureDiff).to equal(Ruact::Testing::FlightStructureDiff)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,135 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
# Story 15.4 (FR108) — the pure Flight wire parser was PROMOTED onto the gem's
|
|
4
|
+
# `lib/` load path as `Ruact::Testing::FlightWireParser` so the new public
|
|
5
|
+
# `have_ruact_component` matcher and the gem's own internal matchers share ONE
|
|
6
|
+
# implementation (wrap, not fork). This file is now a thin shim: it loads the
|
|
7
|
+
# promoted implementation and re-exports it under the historical test-only
|
|
8
|
+
# `Ruact::Spec` namespace so the existing internal matcher wrappers and their
|
|
9
|
+
# self-tests keep working unchanged.
|
|
10
|
+
require "ruact/testing/flight_wire_parser"
|
|
5
11
|
|
|
6
12
|
module Ruact
|
|
7
13
|
# Test-support utilities. Code under `Ruact::Spec` is consumed only by the
|
|
8
14
|
# gem's own RSpec suite; it is not part of the public API and may change
|
|
9
|
-
# shape across stories without a deprecation cycle.
|
|
15
|
+
# shape across stories without a deprecation cycle. The parser/diff it names
|
|
16
|
+
# are aliases for the shipped `Ruact::Testing::*` implementation.
|
|
10
17
|
module Spec
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# author can locate the problem in a printed wire string.
|
|
14
|
-
class FlightWireParseError < StandardError; end
|
|
15
|
-
|
|
16
|
-
# Parses a Flight wire byte string into an ordered array of row records.
|
|
17
|
-
#
|
|
18
|
-
# Used by the structural Flight RSpec matchers
|
|
19
|
-
# (`match_flight_structure` / `include_flight_row`) to assert on parsed
|
|
20
|
-
# semantics rather than literal bytes. Pure function — no I/O, no global
|
|
21
|
-
# state, no `Thread.current`.
|
|
22
|
-
#
|
|
23
|
-
# @example
|
|
24
|
-
# wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
|
|
25
|
-
# Ruact::Spec::FlightWireParser.parse(wire)
|
|
26
|
-
# # => [
|
|
27
|
-
# # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
|
|
28
|
-
# # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
|
|
29
|
-
# # ]
|
|
30
|
-
class FlightWireParser
|
|
31
|
-
# Parse a complete Flight wire byte string.
|
|
32
|
-
#
|
|
33
|
-
# @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
|
|
34
|
-
# @return [Array<Hash>] one hash per row, in wire order. See class docs
|
|
35
|
-
# for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
|
|
36
|
-
# @raise [Ruact::Spec::FlightWireParseError] when a row is malformed.
|
|
37
|
-
def self.parse(wire)
|
|
38
|
-
rows = []
|
|
39
|
-
scanner = StringScanner.new(wire)
|
|
40
|
-
|
|
41
|
-
until scanner.eos?
|
|
42
|
-
start_offset = scanner.pos
|
|
43
|
-
rows << parse_row(scanner, start_offset)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
rows
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def self.parse_row(scanner, start_offset)
|
|
50
|
-
# Hint rows have no ID: ":H<code><json>\n"
|
|
51
|
-
if scanner.peek(2) == ":H"
|
|
52
|
-
scanner.pos += 2
|
|
53
|
-
code = scanner.getch
|
|
54
|
-
raise_parse_error(start_offset, "missing hint code char") if code.nil?
|
|
55
|
-
|
|
56
|
-
json = read_to_newline(scanner, start_offset)
|
|
57
|
-
return {
|
|
58
|
-
id: nil,
|
|
59
|
-
class: :hint,
|
|
60
|
-
payload: [code, parse_json(json, start_offset)],
|
|
61
|
-
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
62
|
-
}
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
|
|
66
|
-
scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
|
|
67
|
-
id = hex.to_i(16)
|
|
68
|
-
|
|
69
|
-
case scanner.peek(1)
|
|
70
|
-
when "I" then parse_tagged(:import, scanner, id, start_offset)
|
|
71
|
-
when "T" then parse_text_row(scanner, id, start_offset)
|
|
72
|
-
when "E" then parse_tagged(:error, scanner, id, start_offset)
|
|
73
|
-
else parse_model_row(scanner, id, start_offset)
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def self.parse_tagged(klass, scanner, id, start_offset)
|
|
78
|
-
scanner.getch # consume the tag byte (I or E)
|
|
79
|
-
json = read_to_newline(scanner, start_offset)
|
|
80
|
-
{
|
|
81
|
-
id: id,
|
|
82
|
-
class: klass,
|
|
83
|
-
payload: parse_json(json, start_offset),
|
|
84
|
-
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
85
|
-
}
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def self.parse_model_row(scanner, id, start_offset)
|
|
89
|
-
json = read_to_newline(scanner, start_offset)
|
|
90
|
-
{
|
|
91
|
-
id: id,
|
|
92
|
-
class: :model,
|
|
93
|
-
payload: parse_json(json, start_offset),
|
|
94
|
-
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
95
|
-
}
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def self.parse_text_row(scanner, id, start_offset)
|
|
99
|
-
scanner.getch # consume "T"
|
|
100
|
-
len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
|
|
101
|
-
scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
|
|
102
|
-
len = len_hex.to_i(16)
|
|
103
|
-
|
|
104
|
-
text = scanner.peek(len)
|
|
105
|
-
raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
|
|
106
|
-
|
|
107
|
-
scanner.pos += len
|
|
108
|
-
{
|
|
109
|
-
id: id,
|
|
110
|
-
class: :text,
|
|
111
|
-
payload: text,
|
|
112
|
-
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
113
|
-
}
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def self.read_to_newline(scanner, start_offset)
|
|
117
|
-
line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
|
|
118
|
-
line.chomp
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def self.parse_json(str, offset)
|
|
122
|
-
JSON.parse(str)
|
|
123
|
-
rescue JSON::ParserError => e
|
|
124
|
-
raise_parse_error(offset, "invalid JSON: #{e.message}")
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def self.raise_parse_error(offset, reason)
|
|
128
|
-
raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
|
|
132
|
-
:read_to_newline, :parse_json, :raise_parse_error
|
|
133
|
-
end
|
|
18
|
+
FlightWireParser = Ruact::Testing::FlightWireParser
|
|
19
|
+
FlightWireParseError = Ruact::Testing::FlightWireParseError
|
|
134
20
|
end
|
|
135
21
|
end
|
|
@@ -1,267 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../flight_wire_parser"
|
|
4
|
+
require "ruact/testing/flight_structure_diff"
|
|
4
5
|
|
|
5
6
|
module Ruact
|
|
6
7
|
module Spec
|
|
7
|
-
#
|
|
8
|
-
# `Ruact::
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# may only reference these keys; unknown keys raise upfront so typos
|
|
14
|
-
# like `payloed:` don't silently match every row via `nil == nil`.
|
|
15
|
-
KNOWN_ROW_KEYS = %i[id class payload raw].freeze
|
|
16
|
-
|
|
17
|
-
# Keys that must be present in every expected-row hash passed to
|
|
18
|
-
# `match_flight_structure`. Excludes `:raw` because expected rows are
|
|
19
|
-
# authored as semantic descriptions, not byte-level snapshots.
|
|
20
|
-
REQUIRED_EXPECTED_KEYS = %i[id class payload].freeze
|
|
21
|
-
|
|
22
|
-
# Compute the set of differences between actual parsed rows and the
|
|
23
|
-
# expected structure. Import rows are matched as a multiset (their
|
|
24
|
-
# relative order among each other is not significant per AC1);
|
|
25
|
-
# everything else compares positionally.
|
|
26
|
-
def self.compute(actual_rows, expected_rows)
|
|
27
|
-
validate_expected_rows!(expected_rows)
|
|
28
|
-
return diffs_for_length_mismatch(actual_rows, expected_rows) if actual_rows.length != expected_rows.length
|
|
29
|
-
|
|
30
|
-
diffs = []
|
|
31
|
-
pending_actual_imports = []
|
|
32
|
-
pending_expected_imports = []
|
|
33
|
-
|
|
34
|
-
actual_rows.zip(expected_rows).each_with_index do |(actual, expected), i|
|
|
35
|
-
if actual[:class] == :import && expected[:class] == :import
|
|
36
|
-
pending_actual_imports << [i, actual]
|
|
37
|
-
pending_expected_imports << [i, expected]
|
|
38
|
-
next
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
next if rows_equal?(actual, expected)
|
|
42
|
-
|
|
43
|
-
diffs << build_field_diff(i, actual, expected)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
diffs.concat(diff_imports_unordered(pending_actual_imports, pending_expected_imports))
|
|
47
|
-
diffs.sort_by { |d| d[:idx] }
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Imports are an unordered multiset within their class (AC1). Sort both
|
|
51
|
-
# sides by `:id` (always unique per render) and any leftover diffs come
|
|
52
|
-
# from semantic mismatch in the same-position-after-sort pair.
|
|
53
|
-
def self.diff_imports_unordered(actual_pairs, expected_pairs)
|
|
54
|
-
return [] if actual_pairs.empty? && expected_pairs.empty?
|
|
55
|
-
|
|
56
|
-
sort_key = ->(pair) { [pair[1][:id].to_i, pair[1][:payload].to_s] }
|
|
57
|
-
sorted_actual = actual_pairs.sort_by(&sort_key)
|
|
58
|
-
sorted_expected = expected_pairs.sort_by(&sort_key)
|
|
59
|
-
|
|
60
|
-
sorted_actual.zip(sorted_expected).filter_map do |(act_idx, act_row), (_exp_idx, exp_row)|
|
|
61
|
-
next if rows_equal?(act_row, exp_row)
|
|
62
|
-
|
|
63
|
-
build_field_diff(act_idx, act_row, exp_row)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def self.diffs_for_length_mismatch(actual_rows, expected_rows)
|
|
68
|
-
diffs = []
|
|
69
|
-
[actual_rows.length, expected_rows.length].max.times do |i|
|
|
70
|
-
actual = actual_rows[i]
|
|
71
|
-
expected = expected_rows[i]
|
|
72
|
-
if actual.nil?
|
|
73
|
-
diffs << { kind: :missing, idx: i, expected_row: expected }
|
|
74
|
-
elsif expected.nil?
|
|
75
|
-
diffs << { kind: :extra, idx: i, actual_row: actual }
|
|
76
|
-
elsif !rows_equal?(actual, expected)
|
|
77
|
-
diffs << build_field_diff(i, actual, expected)
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
diffs
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def self.rows_equal?(actual, expected)
|
|
84
|
-
REQUIRED_EXPECTED_KEYS.all? { |k| actual[k] == expected[k] }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def self.build_field_diff(idx, actual, expected)
|
|
88
|
-
field_diff = nil
|
|
89
|
-
REQUIRED_EXPECTED_KEYS.each do |key|
|
|
90
|
-
next if actual[key] == expected[key]
|
|
91
|
-
|
|
92
|
-
path, sub_expected, sub_actual = first_difference(expected[key], actual[key], ".#{key}")
|
|
93
|
-
field_diff = { path: path, expected: sub_expected, got: sub_actual }
|
|
94
|
-
break
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
kind: :differs,
|
|
99
|
-
idx: idx,
|
|
100
|
-
row_class: actual[:class],
|
|
101
|
-
path: field_diff[:path],
|
|
102
|
-
expected: field_diff[:expected],
|
|
103
|
-
got: field_diff[:got],
|
|
104
|
-
expected_row: expected,
|
|
105
|
-
got_row: actual
|
|
106
|
-
}
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Walks parallel structures (Hash/Array/scalar) and returns the path,
|
|
110
|
-
# expected leaf, and actual leaf at the first differing position.
|
|
111
|
-
def self.first_difference(expected, actual, path)
|
|
112
|
-
return [path, expected, actual] if expected.class != actual.class
|
|
113
|
-
return [path, expected, actual] unless expected.is_a?(Array) || expected.is_a?(Hash)
|
|
114
|
-
|
|
115
|
-
return diff_in_array(expected, actual, path) if expected.is_a?(Array)
|
|
116
|
-
|
|
117
|
-
diff_in_hash(expected, actual, path)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def self.diff_in_array(expected, actual, path)
|
|
121
|
-
[expected.length, actual.length].max.times do |i|
|
|
122
|
-
return ["#{path}[#{i}]", expected[i], actual[i]] if i >= expected.length || i >= actual.length
|
|
123
|
-
next if expected[i] == actual[i]
|
|
124
|
-
|
|
125
|
-
return first_difference(expected[i], actual[i], "#{path}[#{i}]")
|
|
126
|
-
end
|
|
127
|
-
[path, expected, actual]
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def self.diff_in_hash(expected, actual, path)
|
|
131
|
-
(expected.keys | actual.keys).each do |key|
|
|
132
|
-
return ["#{path}[#{key.inspect}]", expected[key], actual[key]] if !expected.key?(key) || !actual.key?(key)
|
|
133
|
-
next if expected[key] == actual[key]
|
|
134
|
-
|
|
135
|
-
return first_difference(expected[key], actual[key], "#{path}[#{key.inspect}]")
|
|
136
|
-
end
|
|
137
|
-
[path, expected, actual]
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Validates each expected row carries the required `:id`, `:class`,
|
|
141
|
-
# `:payload` keys. Without this, an expected row of `{ id: 0, class:
|
|
142
|
-
# :model }` would silently pass against any actual row whose payload
|
|
143
|
-
# was nil — because the `==` check reads `expected[:payload]` as nil.
|
|
144
|
-
def self.validate_expected_rows!(expected_rows)
|
|
145
|
-
expected_rows.each_with_index do |row, i|
|
|
146
|
-
unless row.is_a?(Hash)
|
|
147
|
-
raise ArgumentError,
|
|
148
|
-
"match_flight_structure: expected row #{i} must be a Hash, got #{row.class}: #{row.inspect}"
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
missing = REQUIRED_EXPECTED_KEYS.reject { |k| row.key?(k) }
|
|
152
|
-
next if missing.empty?
|
|
153
|
-
|
|
154
|
-
raise ArgumentError,
|
|
155
|
-
"match_flight_structure: expected row #{i} is missing required keys: #{missing.inspect}. " \
|
|
156
|
-
"Each expected row must include :id, :class, and :payload."
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Validates a predicate hash for `include_flight_row`. Unknown keys
|
|
161
|
-
# (typos like `payloed:` or `clas:`) raise immediately so they don't
|
|
162
|
-
# silently match any row via `row[:payloed]` returning nil.
|
|
163
|
-
def self.validate_predicate!(predicate)
|
|
164
|
-
unless predicate.is_a?(Hash)
|
|
165
|
-
raise ArgumentError,
|
|
166
|
-
"include_flight_row: predicate must be a Hash, got #{predicate.class}: #{predicate.inspect}"
|
|
167
|
-
end
|
|
168
|
-
raise ArgumentError, "include_flight_row: predicate cannot be empty" if predicate.empty?
|
|
169
|
-
|
|
170
|
-
unknown = predicate.keys - KNOWN_ROW_KEYS
|
|
171
|
-
return if unknown.empty?
|
|
172
|
-
|
|
173
|
-
raise ArgumentError,
|
|
174
|
-
"include_flight_row: predicate has unknown keys: #{unknown.inspect}. " \
|
|
175
|
-
"Allowed keys: #{KNOWN_ROW_KEYS.inspect}."
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def self.format_single(diff)
|
|
179
|
-
case diff[:kind]
|
|
180
|
-
when :missing
|
|
181
|
-
row = diff[:expected_row]
|
|
182
|
-
"Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
|
|
183
|
-
when :extra
|
|
184
|
-
row = diff[:actual_row]
|
|
185
|
-
"Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
|
|
186
|
-
else
|
|
187
|
-
format_field_diff(diff)
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def self.format_field_diff(diff)
|
|
192
|
-
<<~MSG.strip
|
|
193
|
-
Expected Flight output to match structure.
|
|
194
|
-
|
|
195
|
-
Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
|
|
196
|
-
expected: #{diff[:expected].inspect}
|
|
197
|
-
got: #{diff[:got].inspect}
|
|
198
|
-
|
|
199
|
-
Row #{diff[:idx]} (#{diff[:row_class]}) full diff:
|
|
200
|
-
expected: #{diff[:expected_row][:payload].inspect}
|
|
201
|
-
got: #{diff[:got_row][:payload].inspect}
|
|
202
|
-
MSG
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# Builds the multi-row failure message: header naming the diff count,
|
|
206
|
-
# AC3-specified wording for missing / extra / differing rows, and a
|
|
207
|
-
# `Row N (<class>): ✓` summary line for every matching row so the
|
|
208
|
-
# reader can see what passed.
|
|
209
|
-
def self.format_multi(diffs, actual_rows, expected_rows)
|
|
210
|
-
header = "Expected Flight output to match structure. #{diffs.length} rows differ:"
|
|
211
|
-
total = [actual_rows.length, expected_rows.length].max
|
|
212
|
-
diff_by_idx = diffs.to_h { |d| [d[:idx], d] }
|
|
213
|
-
|
|
214
|
-
body = (0...total).map do |i|
|
|
215
|
-
diff = diff_by_idx[i]
|
|
216
|
-
if diff
|
|
217
|
-
format_entry(diff)
|
|
218
|
-
else
|
|
219
|
-
row_class = (actual_rows[i] || expected_rows[i])[:class]
|
|
220
|
-
"Row #{i} (#{row_class}): ✓"
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
([header, ""] + body).join("\n")
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def self.format_entry(diff)
|
|
228
|
-
case diff[:kind]
|
|
229
|
-
when :missing
|
|
230
|
-
row = diff[:expected_row]
|
|
231
|
-
"Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
|
|
232
|
-
when :extra
|
|
233
|
-
row = diff[:actual_row]
|
|
234
|
-
"Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
|
|
235
|
-
else
|
|
236
|
-
<<~ENTRY.strip
|
|
237
|
-
Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
|
|
238
|
-
expected: #{diff[:expected].inspect}
|
|
239
|
-
got: #{diff[:got].inspect}
|
|
240
|
-
ENTRY
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Subset / case-equality match used by `include_flight_row`. Plain
|
|
245
|
-
# values use `==`; richer matchers (`hash_including`, `array_including`,
|
|
246
|
-
# `kind_of`, regexes) use `===` which delegates to their custom logic.
|
|
247
|
-
# Predicate keys are validated upfront by `validate_predicate!`, so
|
|
248
|
-
# this method can assume every key is one of the known row keys.
|
|
249
|
-
def self.row_matches?(row, predicate)
|
|
250
|
-
predicate.all? do |key, expected_value|
|
|
251
|
-
actual_value = row[key]
|
|
252
|
-
case expected_value
|
|
253
|
-
when Symbol, Numeric, NilClass, TrueClass, FalseClass
|
|
254
|
-
expected_value == actual_value
|
|
255
|
-
else
|
|
256
|
-
# rubocop:disable Style/CaseEquality -- intentional: lets RSpec mock argument matchers
|
|
257
|
-
# (hash_including, array_including, kind_of, etc.) drive predicate semantics via #===.
|
|
258
|
-
expected_value === actual_value
|
|
259
|
-
# rubocop:enable Style/CaseEquality
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
# rubocop:enable Metrics/ClassLength
|
|
8
|
+
# Story 15.4 (FR108) — the structural diff engine was PROMOTED onto the
|
|
9
|
+
# gem's `lib/` load path as `Ruact::Testing::FlightStructureDiff` (shared,
|
|
10
|
+
# single implementation — wrap, not fork). This test-only `Ruact::Spec`
|
|
11
|
+
# alias keeps the internal matcher wrappers below and their self-tests
|
|
12
|
+
# working unchanged.
|
|
13
|
+
FlightStructureDiff = Ruact::Testing::FlightStructureDiff
|
|
265
14
|
end
|
|
266
15
|
end
|
|
267
16
|
|
|
@@ -151,11 +151,36 @@ export const __RUNTIME_VERSION__: number;
|
|
|
151
151
|
*
|
|
152
152
|
* The gem's own headers (`Accept`, `Content-Type`, `X-CSRF-Token`)
|
|
153
153
|
* win over `defaultHeaders` — CSRF cannot be silently overridden.
|
|
154
|
+
*
|
|
155
|
+
* Story 15.6 (FR110) — `autoRevalidate` (default `false`) is the app-wide
|
|
156
|
+
* auto-revalidate default: when `true`, every successful, non-redirecting
|
|
157
|
+
* mutation `await`s an in-place Flight refresh of the current path (via
|
|
158
|
+
* `revalidate()`) before resolving with the action's JSON result. A per-call
|
|
159
|
+
* `withRefresh(accessor)` wins over this global default.
|
|
154
160
|
*/
|
|
155
161
|
export function configureRuactRuntime(options: {
|
|
156
162
|
defaultHeaders?: Record<string, string> | (() => Record<string, string>) | null;
|
|
163
|
+
autoRevalidate?: boolean;
|
|
157
164
|
}): void;
|
|
158
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Story 15.6 (FR110) — per-call auto-revalidate wrapper. Wraps a generated
|
|
168
|
+
* server-function accessor and returns a new accessor that forces an in-place
|
|
169
|
+
* Flight refresh of the current path after a successful, non-redirecting call —
|
|
170
|
+
* regardless of the app-wide `autoRevalidate` default (per-call explicit wins).
|
|
171
|
+
* Runtime-only: it does not change the codegen or the wire.
|
|
172
|
+
*
|
|
173
|
+
* A `$redirect` response still wins (the refresh is skipped); a failed mutation
|
|
174
|
+
* still throws before any refresh; and if no router is installed the descriptive
|
|
175
|
+
* `revalidate()` error surfaces (a successful mutation whose refresh rejects
|
|
176
|
+
* rejects the returned promise).
|
|
177
|
+
*
|
|
178
|
+
* The wrapped accessor keeps its own call signature (the same
|
|
179
|
+
* `(arg1?, arg2?) => Promise<unknown>` / `(formData) => Promise<void>`
|
|
180
|
+
* intersection the codegen emits).
|
|
181
|
+
*/
|
|
182
|
+
export function withRefresh<F extends (...args: never[]) => Promise<unknown>>(accessor: F): F;
|
|
183
|
+
|
|
159
184
|
/**
|
|
160
185
|
* Re-run-4 (2026-05-15) — structured error thrown for 4xx/5xx responses.
|
|
161
186
|
* Callers can branch on `status` and inspect `body` (already
|