ruact 0.0.11 → 0.0.12
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 +110 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +14 -4
- data/RELEASING.md +125 -139
- data/lib/ruact/packaging.rb +68 -0
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- metadata +6 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -12
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- data/spec/support/rails_stub.rb +0 -115
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
|
|
5
|
-
module Ruact
|
|
6
|
-
RSpec.describe ErbPreprocessorHook do
|
|
7
|
-
# Minimal stand-in for ActionView::Template::Handlers::ERB:
|
|
8
|
-
# base implementation just returns source unchanged so we can inspect the
|
|
9
|
-
# transformed version that the hook passes to super.
|
|
10
|
-
let(:handler_class) do
|
|
11
|
-
klass = Class.new do
|
|
12
|
-
def call(_template, source)
|
|
13
|
-
source
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
klass.prepend(described_class)
|
|
17
|
-
klass
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
let(:handler) { handler_class.new }
|
|
21
|
-
# Story 13.5 — the hook now forwards +template.identifier+ into the
|
|
22
|
-
# preprocessor (for contract-violation file:line), so the stand-in template
|
|
23
|
-
# must answer +identifier+.
|
|
24
|
-
let(:fake_template) { Struct.new(:identifier).new("app/views/posts/show.html.erb") }
|
|
25
|
-
|
|
26
|
-
describe "#call" do
|
|
27
|
-
it "applies ErbPreprocessor.transform to source before calling super" do
|
|
28
|
-
source = "<LikeButton postId={1} />"
|
|
29
|
-
result = handler.call(fake_template, source)
|
|
30
|
-
expect(result).to include("__ruact_component__")
|
|
31
|
-
expect(result).to include('"LikeButton"')
|
|
32
|
-
expect(result).not_to include("<LikeButton")
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
it "passes source unchanged when no PascalCase tags present (fast-path)" do
|
|
36
|
-
source = "<div class=\"hello\"><p>No ruact here</p></div>"
|
|
37
|
-
result = handler.call(fake_template, source)
|
|
38
|
-
expect(result).to eq(source)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
it "processes multiple components in a single template" do
|
|
42
|
-
source = "<NavBar /><LikeButton postId={1} />"
|
|
43
|
-
result = handler.call(fake_template, source)
|
|
44
|
-
expect(result).to include('"NavBar"')
|
|
45
|
-
expect(result).to include('"LikeButton"')
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
it "transforms Suspense tags correctly" do
|
|
49
|
-
source = "<Suspense fallback=\"Loading\"><PostCard /></Suspense>"
|
|
50
|
-
result = handler.call(fake_template, source)
|
|
51
|
-
expect(result).to include("__ruact_component__")
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
@@ -1,361 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
|
|
5
|
-
module Ruact
|
|
6
|
-
RSpec.describe ErbPreprocessor do
|
|
7
|
-
subject(:transform) { ->(source) { described_class.transform(source) } }
|
|
8
|
-
|
|
9
|
-
describe "self-closing tags" do
|
|
10
|
-
it "transforms a self-closing tag with no props" do
|
|
11
|
-
expect(transform.call("<Button />")).to eq(%(<%= __ruact_component__("Button", {}) %>))
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
it "transforms a self-closing tag with props" do
|
|
15
|
-
result = transform.call("<LikeButton postId={@post.id} initialCount={5} />")
|
|
16
|
-
expect(result).to eq(%(<%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>))
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
describe "opening tags" do
|
|
21
|
-
it "transforms an opening tag with props" do
|
|
22
|
-
result = transform.call("<Dialog open={true}>")
|
|
23
|
-
expect(result).to eq(%(<%= __ruact_component__("Dialog", { "open" => true }) %>))
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
describe "passthrough (no transformation)" do
|
|
28
|
-
it "does not touch lowercase HTML tags" do
|
|
29
|
-
source = '<div class="foo"><span>hello</span></div>'
|
|
30
|
-
expect(transform.call(source)).to eq(source)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
it "does not touch ERB tags" do
|
|
34
|
-
source = "<%= @post.title %>"
|
|
35
|
-
expect(transform.call(source)).to eq(source)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
describe "complex prop expressions" do
|
|
40
|
-
it "handles nested braces in a prop value" do
|
|
41
|
-
result = transform.call("<Select options={Category.all.map { |c| c.id }} />")
|
|
42
|
-
expect(result).to eq(%(<%= __ruact_component__("Select", { "options" => Category.all.map { |c| c.id } }) %>))
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
describe "error handling" do
|
|
47
|
-
it "raises PreprocessorError with line number and snippet for unclosed brace (AC#3)" do
|
|
48
|
-
source = "<LikeButton postId={@post.id />"
|
|
49
|
-
expect { transform.call(source) }
|
|
50
|
-
.to raise_error(PreprocessorError, /unclosed brace/)
|
|
51
|
-
expect { transform.call(source) }
|
|
52
|
-
.to raise_error(PreprocessorError, /line 1/)
|
|
53
|
-
expect { transform.call(source) }
|
|
54
|
-
.to raise_error(PreprocessorError, /LikeButton/)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
it "includes the correct line number for an error on line 3" do
|
|
58
|
-
source = "line1\nline2\n<Bad prop={unclosed />"
|
|
59
|
-
expect { transform.call(source) }
|
|
60
|
-
.to raise_error(PreprocessorError, /line 3/)
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
describe "multiple components" do
|
|
65
|
-
it "transforms multiple components in the same string" do
|
|
66
|
-
source = '<Button /> and <Badge label={"hello"} />'
|
|
67
|
-
result = transform.call(source)
|
|
68
|
-
expect(result).to match(/__ruact_component__\("Button"/)
|
|
69
|
-
expect(result).to match(/__ruact_component__\("Badge"/)
|
|
70
|
-
expect(result).to match(/"hello"/)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
describe "mixed content" do
|
|
75
|
-
it "preserves surrounding HTML while transforming components" do
|
|
76
|
-
source = <<~ERB
|
|
77
|
-
<div class="container">
|
|
78
|
-
<h1>Hello</h1>
|
|
79
|
-
<LikeButton postId={1} />
|
|
80
|
-
</div>
|
|
81
|
-
ERB
|
|
82
|
-
result = transform.call(source)
|
|
83
|
-
expect(result).to match(/<div class="container">/)
|
|
84
|
-
expect(result).to match(%r{<h1>Hello</h1>})
|
|
85
|
-
expect(result).to match(/__ruact_component__\("LikeButton"/)
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Bugfix (Sprint Change Proposal 2026-06-16 §4.5): `<Suspense delay="2.5">`
|
|
90
|
-
# used to be ignored — the preprocessor never carried the attribute, so the
|
|
91
|
-
# SuspenseElement always took its default delay. It now forwards an optional
|
|
92
|
-
# `delay` as `data-ruact-delay` on the emitted <ruact-suspense> element.
|
|
93
|
-
describe "Suspense delay attribute" do
|
|
94
|
-
it "forwards delay to the ruact-suspense element as data-ruact-delay" do
|
|
95
|
-
result = transform.call(%(<Suspense fallback="loading" delay="2.5"><Spinner /></Suspense>))
|
|
96
|
-
expect(result).to include(%(data-ruact-delay="2.5"))
|
|
97
|
-
expect(result).to include(%(data-ruact-fallback="loading"))
|
|
98
|
-
expect(result).to include("</ruact-suspense>")
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it "omits data-ruact-delay when no delay attribute is present" do
|
|
102
|
-
result = transform.call(%(<Suspense fallback="loading"><Spinner /></Suspense>))
|
|
103
|
-
expect(result).to include(%(data-ruact-fallback="loading"))
|
|
104
|
-
expect(result).not_to include("data-ruact-delay")
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
it "extracts delay regardless of attribute order" do
|
|
108
|
-
result = transform.call(%(<Suspense delay="0.75" fallback="wait"><X /></Suspense>))
|
|
109
|
-
expect(result).to include(%(data-ruact-delay="0.75"))
|
|
110
|
-
expect(result).to include(%(data-ruact-fallback="wait"))
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
it "accepts a single-quoted delay attribute" do
|
|
114
|
-
result = transform.call(%(<Suspense fallback='loading' delay='1.5'><X /></Suspense>))
|
|
115
|
-
expect(result).to include(%(data-ruact-delay="1.5"))
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Story 15.2 (FR106) — children inside a PascalCase component tag (a matching
|
|
120
|
-
# closing tag) fail LOUDLY at preprocess time instead of degrading silently.
|
|
121
|
-
describe "loud children error (FR106)", :story_15_2 do
|
|
122
|
-
def run(source, identifier: nil)
|
|
123
|
-
described_class.transform(source, identifier: identifier)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
it "raises on `<Card>Hello</Card>` naming component + fix (AC#1)" do
|
|
127
|
-
expect { run("<Card>Hello</Card>") }
|
|
128
|
-
.to raise_error(ChildrenNotSupportedError) do |e|
|
|
129
|
-
expect(e.message).to include("Card")
|
|
130
|
-
expect(e.message).to include("children are not supported")
|
|
131
|
-
expect(e.message).to include("pass content as a prop")
|
|
132
|
-
expect(e.message).to include("<Card content={...} />")
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
it "is a Ruact::PreprocessorError (AC#1 — subclass IS-A base)" do
|
|
137
|
-
expect { run("<Card>Hello</Card>") }.to raise_error(Ruact::PreprocessorError)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
it "raises on an empty pair `<Card></Card>` (AC#1)" do
|
|
141
|
-
expect { run("<Card></Card>") }.to raise_error(ChildrenNotSupportedError, /Card/)
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
it "raises on multi-line children (AC#1)" do
|
|
145
|
-
source = "<Card>\n <p>hi</p>\n</Card>"
|
|
146
|
-
expect { run(source) }.to raise_error(ChildrenNotSupportedError, /children are not supported/)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
it "raises on a paired tag that also holds a nested component" do
|
|
150
|
-
expect { run("<Card><Button /></Card>") }
|
|
151
|
-
.to raise_error(ChildrenNotSupportedError, /<Card>/)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
it "names the supplied identifier and the correct line for a non-first-line pair (AC#4)" do
|
|
155
|
-
source = "line1\nline2\n<Card>Hello</Card>"
|
|
156
|
-
expect { run(source, identifier: "app/views/posts/show.html.erb") }
|
|
157
|
-
.to raise_error(ChildrenNotSupportedError) do |e|
|
|
158
|
-
expect(e.message).to include("app/views/posts/show.html.erb:3")
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
it "reports the opening tag's line, honoring attributes on the opening tag" do
|
|
163
|
-
expect { run("<Card variant={:wide}>x</Card>", identifier: "t.erb") }
|
|
164
|
-
.to raise_error(ChildrenNotSupportedError, /t\.erb:1/)
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Regression (Codex Round 1, Patch 1): a MULTI-LINE Suspense opening tag
|
|
168
|
-
# must not shift the reported line — the `<Card>` below is physically on
|
|
169
|
-
# line 5 and must report :5 (Suspense is masked newline-for-newline).
|
|
170
|
-
it "reports the exact source line even after a multi-line Suspense opening" do
|
|
171
|
-
source = <<~ERB
|
|
172
|
-
<Suspense
|
|
173
|
-
fallback="loading"
|
|
174
|
-
delay="2.5">
|
|
175
|
-
</Suspense>
|
|
176
|
-
<Card>Hello</Card>
|
|
177
|
-
ERB
|
|
178
|
-
expect { run(source, identifier: "t.erb") }
|
|
179
|
-
.to raise_error(ChildrenNotSupportedError, /t\.erb:5/)
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# Regression (Codex Round 2, Patch 1): a `</Dialog>` living inside an ERB
|
|
183
|
-
# island (Ruby string/comment) must NOT be mistaken for a real component
|
|
184
|
-
# closing tag — a valid bare `<Dialog open={true}>` stays valid.
|
|
185
|
-
it "does not false-pair a bare opening with a `</Tag>` inside an ERB island" do
|
|
186
|
-
source = %(<Dialog open={true}>\n<% x = "</Dialog>" %>)
|
|
187
|
-
expect { run(source) }.not_to raise_error
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
it "still fires when the closing tag is real ERB body, not inside `<% %>`" do
|
|
191
|
-
expect { run("<Card><%= @body %></Card>") }
|
|
192
|
-
.to raise_error(ChildrenNotSupportedError, /Card/)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# Regression (Codex Round 2, Patch 2): many bare non-self-closing openings
|
|
196
|
-
# with no close must stay linear (single-pass stack scan) and silent (D3).
|
|
197
|
-
it "stays silent and does not blow up on many bare unclosed openings" do
|
|
198
|
-
source = "<Dialog open={true}>\n" * 5000
|
|
199
|
-
expect { run(source) }.not_to raise_error
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# Regression (Codex Round 3, Patch 1): stray/unmatched PascalCase closing
|
|
203
|
-
# tags (no preceding matching open) are literal text — never an error — and
|
|
204
|
-
# must stay linear (per-name O(1) lookup, no `rindex`). Correctness pin;
|
|
205
|
-
# perf verified live, not timed here (avoids a flaky benchmark spec).
|
|
206
|
-
it "does not raise on stray closing tags with no matching opening" do
|
|
207
|
-
source = "</Card>\n" * 5000
|
|
208
|
-
expect { run(source) }.not_to raise_error
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
it "still raises when a real opening precedes the stray closes" do
|
|
212
|
-
source = "<Card>x</Card>\n#{'</Card>' * 100}"
|
|
213
|
-
expect { run(source) }.to raise_error(ChildrenNotSupportedError, /Card/)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# AC#2 regression — the loud error must NOT fire on any valid pattern.
|
|
217
|
-
describe "does NOT fire on valid patterns (AC#2 byte-identical)" do
|
|
218
|
-
it "self-closing tag, no props" do
|
|
219
|
-
expect { run("<Button />") }.not_to raise_error
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
it "self-closing tag with props" do
|
|
223
|
-
expect { run("<LikeButton postId={@post.id} />") }.not_to raise_error
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
it "bare non-self-closing opening tag with NO closing tag (`<Dialog open={true}>`)" do
|
|
227
|
-
expect { run("<Dialog open={true}>") }.not_to raise_error
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
it "nested-brace prop value" do
|
|
231
|
-
expect { run("<Select options={Category.all.map { |c| c.id }} />") }.not_to raise_error
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
it "multiple self-closing components" do
|
|
235
|
-
expect { run('<Button /> and <Badge label={"hello"} />') }.not_to raise_error
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
it "mixed HTML around a self-closing component" do
|
|
239
|
-
source = %(<div class="container">\n <h1>Hi</h1>\n <LikeButton postId={1} />\n</div>)
|
|
240
|
-
expect { run(source) }.not_to raise_error
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
it "emits byte-identical output for `<Dialog open={true}>`" do
|
|
244
|
-
expect(run("<Dialog open={true}>"))
|
|
245
|
-
.to eq(%(<%= __ruact_component__("Dialog", { "open" => true }) %>))
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
# AC#3 — Suspense children are the ONE legitimate paired PascalCase tag and
|
|
250
|
-
# must never trip the error (normalized to <ruact-suspense> in Step 1).
|
|
251
|
-
describe "Suspense children never trip the error (AC#3)" do
|
|
252
|
-
it "does not raise for `<Suspense><Spinner /></Suspense>`" do
|
|
253
|
-
expect { run(%(<Suspense fallback="loading"><Spinner /></Suspense>)) }.not_to raise_error
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
it "normalizes Suspense to <ruact-suspense> exactly as today" do
|
|
257
|
-
result = run(%(<Suspense fallback="loading"><Spinner /></Suspense>))
|
|
258
|
-
expect(result).to include(%(data-ruact-fallback="loading"))
|
|
259
|
-
expect(result).to include("</ruact-suspense>")
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# Story 13.5 (FR100) — preprocess-time component-contract validation, wired
|
|
265
|
-
# through an injectable registry seam (a stub responding to +contract_for+).
|
|
266
|
-
describe "component contract validation", :story_13_5 do
|
|
267
|
-
# A minimal stub registry: maps component name → contract Hash (or nil).
|
|
268
|
-
def registry_for(contracts)
|
|
269
|
-
Class.new do
|
|
270
|
-
def initialize(contracts) = (@contracts = contracts)
|
|
271
|
-
def contract_for(name, **) = @contracts[name]
|
|
272
|
-
end.new(contracts)
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
let(:contract) do
|
|
276
|
-
{ "props" => { "postId" => "required", "initialCount" => "optional" } }
|
|
277
|
-
end
|
|
278
|
-
let(:registry) { registry_for("LikeButton" => contract) }
|
|
279
|
-
|
|
280
|
-
def run(source, identifier: "app/views/posts/show.html.erb")
|
|
281
|
-
described_class.transform(source, identifier: identifier, registry: registry)
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
it "raises on a missing required prop, naming component + file:line + fix (AC#1, AC#3)" do
|
|
285
|
-
expect { run("<LikeButton initialCount={5} />") }
|
|
286
|
-
.to raise_error(ComponentContractError) do |e|
|
|
287
|
-
expect(e.message).to include("LikeButton")
|
|
288
|
-
expect(e.message).to include("app/views/posts/show.html.erb:1")
|
|
289
|
-
expect(e.message).to include("postId")
|
|
290
|
-
expect(e.message).to include("add the required prop")
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
it "raises on an unknown prop with a did-you-mean suggestion (AC#3)" do
|
|
295
|
-
expect { run("<LikeButton postId={1} postID={2} />") }
|
|
296
|
-
.to raise_error(ComponentContractError, /did you mean "postId"\?/)
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
it "computes the correct line for a call site lower in the template" do
|
|
300
|
-
source = "line1\nline2\n<LikeButton initialCount={5} />"
|
|
301
|
-
expect { run(source) }.to raise_error(ComponentContractError, /show\.html\.erb:3/)
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
it "passes a valid call and emits the normal placeholder (AC#1)" do
|
|
305
|
-
result = run("<LikeButton postId={@post.id} initialCount={5} />")
|
|
306
|
-
expect(result)
|
|
307
|
-
.to eq(%(<%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>))
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
it "does NOT re-wrap the contract error with the generic line/snippet tail" do
|
|
311
|
-
expect { run("<LikeButton initialCount={5} />") }
|
|
312
|
-
.to raise_error(ComponentContractError) { |e| expect(e.message).not_to match(/at line \d+:/) }
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# AC#2 — opt-in / byte-identity: a component with no contract entry is
|
|
316
|
-
# validated NOT AT ALL and emits the exact same placeholder as pre-13.5.
|
|
317
|
-
describe "opt-in fail-open (AC#2)" do
|
|
318
|
-
it "emits byte-identical output for a contract-less component" do
|
|
319
|
-
source = "<NavBar foo={1} bar={2} />"
|
|
320
|
-
with_contract = run(source) # registry has NO "NavBar" entry → fail open
|
|
321
|
-
without_registry = described_class.transform(source, registry: nil)
|
|
322
|
-
expected = %(<%= __ruact_component__("NavBar", { "foo" => 1, "bar" => 2 }) %>)
|
|
323
|
-
expect(with_contract).to eq(expected)
|
|
324
|
-
expect(without_registry).to eq(expected)
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
it "never consults the registry for a no-tag source (fast path)" do
|
|
328
|
-
spy_registry = registry_for({})
|
|
329
|
-
allow(spy_registry).to receive(:contract_for).and_call_original
|
|
330
|
-
result = described_class.transform("<div><p>plain</p></div>", registry: spy_registry)
|
|
331
|
-
expect(result).to eq("<div><p>plain</p></div>")
|
|
332
|
-
expect(spy_registry).not_to have_received(:contract_for)
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
# Codex review (Patch 2) — the DEFAULT registry (`Ruact.manifest`) must
|
|
336
|
-
# not even be read when the source has no component tags.
|
|
337
|
-
it "never reads Ruact.manifest for a no-tag source (default registry)" do
|
|
338
|
-
allow(Ruact).to receive(:manifest)
|
|
339
|
-
described_class.transform("<div><p>plain</p></div>")
|
|
340
|
-
expect(Ruact).not_to have_received(:manifest)
|
|
341
|
-
end
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
describe "slots (AC#5)" do
|
|
345
|
-
let(:contract) do
|
|
346
|
-
{ "props" => { "title" => "required" }, "slots" => { "header" => "required" } }
|
|
347
|
-
end
|
|
348
|
-
let(:registry) { registry_for("Card" => contract) }
|
|
349
|
-
|
|
350
|
-
it "raises when a required slot attribute is omitted at the call site" do
|
|
351
|
-
expect { run("<Card title={@t} />") }
|
|
352
|
-
.to raise_error(ComponentContractError, /missing required slot.*header/m)
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
it "passes when the declared slot is supplied as an attribute" do
|
|
356
|
-
expect { run("<Card title={@t} header={@h} />") }.not_to raise_error
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
|
-
end
|
|
360
|
-
end
|
|
361
|
-
end
|
data/spec/ruact/errors_spec.rb
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
|
|
5
|
-
module Ruact
|
|
6
|
-
RSpec.describe "Error classes" do
|
|
7
|
-
describe "Ruact::Error" do
|
|
8
|
-
it "is a subclass of StandardError" do
|
|
9
|
-
expect(Error.ancestors).to include(StandardError)
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
describe "Ruact::ManifestError" do
|
|
14
|
-
it "is a subclass of Ruact::Error" do
|
|
15
|
-
expect(ManifestError.ancestors).to include(Error)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
it "can be raised and rescued as Ruact::Error" do
|
|
19
|
-
expect { raise ManifestError, "test" }.to raise_error(Error)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
describe "Ruact::SerializationError" do
|
|
24
|
-
it "is a subclass of Ruact::Error" do
|
|
25
|
-
expect(SerializationError.ancestors).to include(Error)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
it "can be raised and rescued as Ruact::Error" do
|
|
29
|
-
expect { raise SerializationError, "test" }.to raise_error(Error)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
describe "Ruact::PreprocessorError" do
|
|
34
|
-
it "is a subclass of Ruact::Error" do
|
|
35
|
-
expect(PreprocessorError.ancestors).to include(Error)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
it "can be raised and rescued as Ruact::Error" do
|
|
39
|
-
expect { raise PreprocessorError, "test" }.to raise_error(Error)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
describe "Ruact::ConfigurationError" do
|
|
44
|
-
it "is a subclass of Ruact::Error" do
|
|
45
|
-
expect(ConfigurationError.ancestors).to include(Error)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
it "can be raised and rescued as Ruact::Error" do
|
|
49
|
-
expect { raise ConfigurationError, "test" }.to raise_error(Error)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
describe "Ruact::HtmlConverterError" do
|
|
54
|
-
it "is a subclass of Ruact::Error" do
|
|
55
|
-
expect(HtmlConverterError.ancestors).to include(Error)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
it "can be raised and rescued as Ruact::Error" do
|
|
59
|
-
expect { raise HtmlConverterError, "test" }.to raise_error(Error)
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
describe "Ruact::UploadTooLargeError", :story_8_5 do
|
|
64
|
-
it "is a subclass of Ruact::Error (so Story 8.4 rescue_from StandardError catches it)" do
|
|
65
|
-
expect(UploadTooLargeError.ancestors).to include(Error)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
it "carries received_bytes and limit_bytes attr_readers" do
|
|
69
|
-
error = UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760)
|
|
70
|
-
expect(error.received_bytes).to eq(11_534_336)
|
|
71
|
-
expect(error.limit_bytes).to eq(10_485_760)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
it "synthesises a default message that includes both numbers" do
|
|
75
|
-
error = UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760)
|
|
76
|
-
expect(error.message).to include("received_bytes=11534336")
|
|
77
|
-
expect(error.message).to include("limit_bytes=10485760")
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
it "honours an explicit custom message" do
|
|
81
|
-
error = UploadTooLargeError.new(received_bytes: 1, limit_bytes: 2, message: "custom")
|
|
82
|
-
expect(error.message).to eq("custom")
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
it "can be rescued as Ruact::Error and StandardError" do
|
|
86
|
-
expect { raise UploadTooLargeError.new(received_bytes: 1, limit_bytes: 0) }
|
|
87
|
-
.to raise_error(Error)
|
|
88
|
-
expect { raise UploadTooLargeError.new(received_bytes: 1, limit_bytes: 0) }
|
|
89
|
-
.to raise_error(StandardError)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
|
|
5
|
-
module Ruact
|
|
6
|
-
module Flight
|
|
7
|
-
RSpec.describe Renderer do
|
|
8
|
-
let(:empty_manifest) { ClientManifest.from_hash({}) }
|
|
9
|
-
let(:manifest_with_button) do
|
|
10
|
-
ClientManifest.from_hash({
|
|
11
|
-
"Button" => {
|
|
12
|
-
"id" => "/assets/Button-abc.js",
|
|
13
|
-
"name" => "Button",
|
|
14
|
-
"chunks" => ["/assets/Button-abc.js"]
|
|
15
|
-
}
|
|
16
|
-
})
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
describe ".render" do
|
|
20
|
-
it "renders ReactElement with no props to fixture" do
|
|
21
|
-
element = ReactElement.new(type: "div")
|
|
22
|
-
output = described_class.render(element, empty_manifest)
|
|
23
|
-
expect(output).to match_flight_fixture("react_element_no_props")
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
it "returns a String" do
|
|
27
|
-
element = ReactElement.new(type: "span")
|
|
28
|
-
expect(described_class.render(element, empty_manifest)).to be_a(String)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
it "root row always has id 0" do
|
|
32
|
-
element = ReactElement.new(type: "p")
|
|
33
|
-
output = described_class.render(element, empty_manifest)
|
|
34
|
-
expect(Ruact::Spec::FlightWireParser.parse(output).last).to include(id: 0, class: :model)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
describe ".each" do
|
|
39
|
-
it "yields rows when called with a block" do
|
|
40
|
-
element = ReactElement.new(type: "div")
|
|
41
|
-
rows = []
|
|
42
|
-
described_class.each(element, empty_manifest) { |row| rows << row }
|
|
43
|
-
expect(rows).not_to be_empty
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
it "produces identical output to .render" do
|
|
47
|
-
element = ReactElement.new(type: "article")
|
|
48
|
-
via_each = described_class.each(element, empty_manifest, streaming: false).to_a.join
|
|
49
|
-
via_render = described_class.render(element, empty_manifest)
|
|
50
|
-
expect(via_each).to eq(via_render)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
it "returns Enumerator when called without block" do
|
|
54
|
-
element = ReactElement.new(type: "div")
|
|
55
|
-
result = described_class.each(element, empty_manifest)
|
|
56
|
-
expect(result).to be_an(Enumerator)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
describe "suspense timeout (Story 3.5 AC #4)" do
|
|
61
|
-
let(:fallback) { ReactElement.new(type: "span") }
|
|
62
|
-
let(:inner) { ReactElement.new(type: "div") }
|
|
63
|
-
|
|
64
|
-
# Configuration is frozen post-Story 7.3, so RSpec mocks cannot proxy it.
|
|
65
|
-
# Configure via Ruact.configure and reset around each example.
|
|
66
|
-
around do |example|
|
|
67
|
-
Ruact.instance_variable_set(:@config, nil)
|
|
68
|
-
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
69
|
-
example.run
|
|
70
|
-
Ruact.instance_variable_set(:@config, nil)
|
|
71
|
-
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
context "when deferred delay exceeds suspense_timeout (streaming: true)" do
|
|
75
|
-
before do
|
|
76
|
-
Ruact.configure { |c| c.suspense_timeout = 1.0 }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
it "emits an E-type error row instead of the model row" do
|
|
80
|
-
suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
|
|
81
|
-
rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
|
|
82
|
-
expect(rows).to include(a_string_matching(/:E/))
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
it "error row contains 'Suspense timeout exceeded'" do
|
|
86
|
-
suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
|
|
87
|
-
rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
|
|
88
|
-
error_row = rows.find { |r| r.include?(":E") }
|
|
89
|
-
# Error message string is the contract — see Story 7.6 Decision E.
|
|
90
|
-
expect(error_row).to include("Suspense timeout exceeded")
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
it "does NOT emit a plain model row for the timed-out chunk" do
|
|
94
|
-
suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
|
|
95
|
-
rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
|
|
96
|
-
# Only root (0:) and the error row (1:E...) should exist — no bare model for deferred id
|
|
97
|
-
unexpected = rows.reject { |r| r.start_with?("0:") || r.include?(":E") }
|
|
98
|
-
expect(unexpected).to be_empty
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
context "when deferred delay is within suspense_timeout (streaming: true)" do
|
|
103
|
-
before do
|
|
104
|
-
Ruact.configure { |c| c.suspense_timeout = 10.0 }
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
it "emits a model row (not an error row) and no E row" do
|
|
108
|
-
suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 0.0)
|
|
109
|
-
rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
|
|
110
|
-
expect(rows).not_to include(a_string_matching(/:E/))
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
describe "import row ordering" do
|
|
116
|
-
it "emits import rows before the root model row" do
|
|
117
|
-
element = ReactElement.new(
|
|
118
|
-
type: ClientReference.new(module_id: "/assets/Button-abc.js", export_name: "Button"),
|
|
119
|
-
props: {}
|
|
120
|
-
)
|
|
121
|
-
output = described_class.render(element, manifest_with_button)
|
|
122
|
-
lines = output.lines.map(&:strip).reject(&:empty?)
|
|
123
|
-
|
|
124
|
-
import_idx = lines.index { |l| l.include?(":I[") }
|
|
125
|
-
root_idx = lines.index { |l| l.start_with?("0:") }
|
|
126
|
-
|
|
127
|
-
expect(import_idx).not_to be_nil
|
|
128
|
-
expect(import_idx).to be < root_idx
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|