ruact 0.0.10 → 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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +16 -6
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -1
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
data/spec/readme_spec.rb DELETED
@@ -1,282 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "nokogiri"
5
- require "pathname"
6
-
7
- # Story 5.14 — the README gate that lives INSIDE this repository.
8
- #
9
- # `README.md` is not an ordinary readme: GitHub renders it on the gem's own
10
- # repository page, `source_code_uri` points at it, and `spec.files` packages it
11
- # *inside* the built `.gem`. Until this story it was still `bundle gem`
12
- # boilerplate ("TODO: Delete this and the text below"), and that boilerplate had
13
- # already shipped to RubyGems.
14
- #
15
- # The planning monorepo has a command-spine gate
16
- # (`docs/examples/getting-started/scripts/check-commands.mjs`) that reads this
17
- # file through the `gem/` submodule and checks its quick start against ONE
18
- # canonical greenfield sequence — the SPINE, which is the single source of
19
- # truth. But a merge in THIS repository fires nothing over there, so a
20
- # README-only PR here would stay unchecked until somebody bumped the submodule
21
- # pointer.
22
- #
23
- # Hence the deliberate duplication below: `expected_quick_start` is a literal
24
- # copy of the quick-start block, owned by this spec, and the monorepo gate
25
- # verifies that literal against the canonical SPINE. The dependency runs one way
26
- # only — this spec reads nothing outside the gem repository, because a public
27
- # artifact that cannot be verified in its own repository is not verifiable.
28
- RSpec.describe "README.md", :story_5_14 do
29
- subject(:readme) { root.join("README.md").read }
30
-
31
- let(:root) { Pathname.new(File.expand_path("..", __dir__)) }
32
-
33
- # Every literal `bundle gem` leaves behind. Each one was present before this
34
- # story; any of them coming back means the scaffolding was re-pasted.
35
- let(:boilerplate) do
36
- [
37
- "TODO:",
38
- "UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG",
39
- "[USERNAME]",
40
- "Welcome to your new gem",
41
- "Put your Ruby code in the file",
42
- "bin/console",
43
- "bundle exec rake release"
44
- ]
45
- end
46
-
47
- # Harness-only gem sources. They exist so the monorepo's playgrounds can test
48
- # the working tree; a reader who copies one installs nothing.
49
- let(:path_gem_sources) do
50
- [
51
- /(^|\s)gem\s+["']ruact["']\s*,\s*path:/,
52
- /--path(\s|=)/
53
- ]
54
- end
55
-
56
- # The quick-start block, byte for byte. Changing it here without changing the
57
- # monorepo SPINE (or the other way round) turns the monorepo gate red.
58
- let(:expected_quick_start) do
59
- <<~BASH
60
- # 1. A throwaway app to try it in
61
- rails new myapp --skip-javascript && cd myapp
62
-
63
- # 2. Add the gem
64
- bundle add ruact
65
-
66
- # 3. Write the config, the layout wiring and an AGENTS.md — then run npm install
67
- rails generate ruact:install
68
-
69
- # 4. Rails + Vite, one command
70
- bin/dev
71
- BASH
72
- end
73
-
74
- # `[text](target)` and `![alt](target)`, inline-title form allowed.
75
- def markdown_link_targets(markdown)
76
- markdown.scan(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/).flatten
77
- end
78
-
79
- # Story 5.2 — the demo reference, pinned BYTE FOR BYTE.
80
- #
81
- # The first version of this gate hand-rolled HTML and CommonMark recognition
82
- # out of regexes, and review corrected it in three consecutive rounds:
83
- # `\bsrc=` matched `data-src=`, a literal `>` inside an attribute ended the
84
- # tag early, fences were only recognised at column 0, then unterminated
85
- # fences were not recognised at all, then unquoted attribute values were
86
- # invisible. Those are not five bugs. Neither HTML nor CommonMark is a
87
- # regular language, so that list has no end, and a gate whose coverage is
88
- # "whatever the regex happens to know this week" is not a gate.
89
- #
90
- # Replaced by three statements that are TOTAL:
91
- #
92
- # 1. the reference is pinned literally — the same move `expected_quick_start`
93
- # above already makes for the quick start;
94
- # 2. the file is allowed EXACTLY ONE raw `<img>`, counted at the byte level;
95
- # and
96
- # 3. the parsed document holds exactly one LIVE `<img>` element, equal to
97
- # the pinned one attribute for attribute.
98
- #
99
- # (1) and (2) together say something the old scanner could only approximate:
100
- # every raw image reference in this README is the pinned one. A second `<img>`
101
- # anywhere — including inside a fenced example, terminated or not — turns
102
- # this red and has to be gated deliberately rather than slipping through a
103
- # blind spot. (3) says the thing bytes cannot: that it renders. The pinned
104
- # block wrapped in an HTML comment satisfies (1) and (2) and shows nothing.
105
- #
106
- # Attributes are then read with Nokogiri, already a runtime dependency of this
107
- # gem (see the gemspec), so unquoted values, `>` inside a value and `data-src`
108
- # are PARSED rather than pattern-matched. What that scope does and does not
109
- # promise is written down in the monorepo's gate inventory,
110
- # docs/examples/getting-started/README.md § "What is still outside both gates".
111
- def expected_demo_img
112
- <<~HTML
113
- <img src="https://ruact.dev/readme-write-verify.gif" width="800"
114
- alt="An ERB template holding a &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. Children are then put inside the tag — the JSX habit — and the next request stops server-side with Ruact::ChildrenNotSupportedError, which names the component, the template file and line, and the fix. The children come out again and the page renders." />
115
- HTML
116
- end
117
-
118
- def demo_src
119
- "https://ruact.dev/readme-write-verify.gif"
120
- end
121
-
122
- # Parsed from the pinned literal, exactly as `expected_quick_start` is the
123
- # authority for the quick start: one example asserts the README contains
124
- # these bytes, and the rest read meaning off them. The chain is
125
- # README -> literal -> parser, and the first link is what makes it honest.
126
- def demo_node
127
- Nokogiri::HTML5.fragment(expected_demo_img).at_css("img")
128
- end
129
-
130
- # Byte-level and case-insensitive on purpose: no parser, no markdown, nothing
131
- # to have a blind spot. `<IMG`, `<img\n`, an `<img` inside a fence — all count.
132
- def raw_img_count(markdown)
133
- markdown.scan(/<img\b/i).length
134
- end
135
-
136
- # …and the same parser turned on the README ITSELF, because the two byte-level
137
- # facts above are about bytes, not about rendering: wrap the pinned block in
138
- # an HTML comment and the literal is still present and the raw count is still
139
- # one, while GitHub shows nothing at all (verified — `include`=true, raw=1,
140
- # live nodes=0). Nokogiri is what decides whether a tag is an element or a
141
- # comment, so it is what the "it renders" half of this gate has to ask.
142
- def readme_img_nodes
143
- Nokogiri::HTML5.fragment(readme).css("img")
144
- end
145
-
146
- # Absolute URLs, anchors and mailto: links are somebody else's problem; on-disk
147
- # targets are ours.
148
- def relative(targets)
149
- targets.reject { |target| target.start_with?("http://", "https://", "#", "mailto:") }
150
- end
151
-
152
- def bash_blocks(markdown)
153
- markdown.scan(/^```bash[ \t]*\n(.*?)^```/m).flatten
154
- end
155
-
156
- it "carries none of the `bundle gem` boilerplate" do
157
- present = boilerplate.select { |literal| readme.include?(literal) }
158
-
159
- expect(present).to be_empty,
160
- "README.md still carries bundler scaffolding: #{present.inspect}"
161
- end
162
-
163
- it "links only to files that exist in this repository" do
164
- targets = relative(markdown_link_targets(readme) + [demo_node["src"]])
165
- missing = targets.reject { |target| root.join(target.split("#").first.to_s).exist? }
166
-
167
- expect(targets).not_to be_empty, "expected the README to link at least one repo-relative file"
168
- expect(missing).to be_empty,
169
- "README.md links files that do not exist in the gem repository: #{missing.inspect}"
170
- end
171
-
172
- # Story 5.2 — the demo GIF is a documentation asset hosted with the site, so
173
- # this repository stays free of binaries: no `.gem` download and no clone
174
- # pays for it. The consequence is that the reference is an absolute URL, and
175
- # an absolute URL cannot be resolved on disk here — the monorepo puts it in
176
- # `website/scripts/verify-urls.mjs`'s PATHS, checked against the deployed
177
- # site, so a dead image goes red there instead of rotting silently.
178
- it "carries the write→verify demo reference, byte for byte" do
179
- expect(readme).to include(expected_demo_img),
180
- "the demo reference in README.md drifted from the literal this spec pins. " \
181
- "Change both or neither — and if the recording itself changed, see " \
182
- "readme_demo_message_spec.rb."
183
- end
184
-
185
- # The other half of the pin: because there is exactly ONE raw `<img>` and the
186
- # example above proves it is the pinned one, "the pinned literal is present"
187
- # and "every raw image in this file is checked" are the same statement.
188
- it "carries exactly one raw <img>, so the pin covers every raw image in the file" do
189
- expect(raw_img_count(readme)).to eq(1),
190
- "README.md has #{raw_img_count(readme)} raw <img> tags. This gate is " \
191
- "written for exactly one — the demo, pinned literally. A second image " \
192
- "must be gated deliberately, not left to a scanner's blind spots."
193
- end
194
-
195
- # And the demo is LIVE, not merely present. Bytes inside an HTML comment
196
- # satisfy both statements above and render nothing; only a parser can tell
197
- # the difference, so the README itself is parsed and the node it yields must
198
- # be the pinned one, attribute for attribute.
199
- it "renders the demo as a real element, not as bytes inside a comment" do
200
- nodes = readme_img_nodes
201
-
202
- expect(nodes.length).to eq(1),
203
- "README.md parses to #{nodes.length} live <img> element(s); the demo must be " \
204
- "exactly one, and must not be commented out."
205
- expect(nodes.first.attributes.transform_values(&:value))
206
- .to eq(demo_node.attributes.transform_values(&:value))
207
- end
208
-
209
- it "keeps the demo hosted with the site rather than committed here" do
210
- expect(demo_node["src"]).to eq(demo_src)
211
-
212
- # `git ls-files` and not a filesystem glob, because that is precisely what
213
- # `spec.files` packages (ruact.gemspec) — untracked build output is not the
214
- # question here, and coverage/ is full of it.
215
- tracked = IO.popen(%w[git ls-files -z], chdir: root.to_s, err: IO::NULL) do |ls|
216
- ls.readlines("\x0", chomp: true)
217
- end
218
- committed_media = tracked.grep(/\.(gif|mp4|webm|webp)\z/i)
219
-
220
- expect(committed_media).to be_empty,
221
- "media committed under gem/ ships inside every `.gem` and stays in the " \
222
- "clone history forever: #{committed_media.inspect}"
223
- end
224
-
225
- # The alt text is the only thing a screen-reader user — or an agent reading
226
- # the README as text — gets. "demo" is not a description.
227
- it "describes the demo's arc in its alt text" do
228
- # Nokogiri resolves the entities, so this reads what a screen reader reads:
229
- # `&lt;LikeButton` is the tag the demo shows, not four literal characters.
230
- alt = demo_node["alt"].to_s
231
-
232
- expect(alt.split.length).to be > 40, "the demo's alt text does not describe the arc: #{alt.inspect}"
233
- expect(alt).to include("Ruact::ChildrenNotSupportedError")
234
- expect(alt).not_to match(/\bbuild\b/i),
235
- "the failure the demo shows happens server-side at render, not at build — " \
236
- "see Story 5.2 AC4"
237
- end
238
-
239
- # Story 5.2, learned the expensive way: YARD parses README.md as the docs'
240
- # main file and read `{@likes}` in the demo's alt text as a link macro it
241
- # could not resolve — and `--fail-on-warning` turned that into a red REQUIRED
242
- # check, for a README edit, in a job whose output says nothing about READMEs.
243
- # Braces belong in prose as `&#123;`/`&#125;`: GitHub renders them, YARD never
244
- # sees them.
245
- #
246
- # SCOPE, deliberately: this checks the pinned demo block, not the whole file.
247
- # Knowing which parts of a markdown document YARD linkifies means knowing
248
- # where the fenced blocks are, and this gate no longer recognises fences —
249
- # that is the trade this redesign makes. `yard --fail-on-warning` in this
250
- # repository's own CI is the TOTAL gate; this example exists so the one line
251
- # that actually tripped it fails locally, in the suite that owns the README,
252
- # naming the cause.
253
- it "keeps YARD link macros out of the demo block, which is what reddened the docs job" do
254
- # `{@ivar}`, `{Class}`, `{Class::Nested}`, `{Class#method}`, `{Class.method}`
255
- # — the shapes YARD resolves. It does not try to resolve anything with a
256
- # space in it, which is why the prose's `{ post: … }` elsewhere is safe.
257
- macros = expected_demo_img.scan(/\{(?:@\w+|[A-Z][\w:.#]*)\}/)
258
-
259
- expect(macros).to be_empty,
260
- "the demo block contains #{macros.inspect}, which YARD tries to resolve as a " \
261
- "link and `yard --fail-on-warning` fails on. Use &#123; / &#125;."
262
- end
263
-
264
- it "shows no harness-only `path:` gem source a reader could copy" do
265
- offenders = path_gem_sources.filter_map { |pattern| pattern.source if readme.match?(pattern) }
266
-
267
- expect(offenders).to be_empty,
268
- "README.md shows a harness-only gem source (#{offenders.inspect}) — " \
269
- "a reader who copies it installs nothing"
270
- end
271
-
272
- it "pins the quick start to the canonical greenfield sequence" do
273
- expect(bash_blocks(readme).first).to eq(expected_quick_start),
274
- "the README quick start drifted. It is checked against the monorepo's " \
275
- "canonical SPINE (docs/examples/getting-started/scripts/" \
276
- "check-commands.mjs); change both or neither."
277
- end
278
-
279
- it "keeps the quick start in a ```bash fence, the only language the monorepo gate reads" do
280
- expect(readme).to include("```bash\n#{expected_quick_start}```")
281
- end
282
- end
@@ -1,270 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "tempfile"
5
-
6
- module Ruact
7
- RSpec.describe ClientManifest do
8
- let(:manifest_data) do
9
- {
10
- "LikeButton" => {
11
- "id" => "/LikeButton.jsx",
12
- "name" => "LikeButton",
13
- "chunks" => ["/LikeButton.jsx"]
14
- }
15
- }
16
- end
17
-
18
- let(:dual_manifest_data) do
19
- {
20
- "LikeButton" => {
21
- "id" => "/LikeButton.jsx",
22
- "name" => "LikeButton",
23
- "chunks" => ["/LikeButton.jsx"]
24
- },
25
- "posts/_like_button" => {
26
- "id" => "/posts/_like_button.jsx",
27
- "name" => "default",
28
- "chunks" => ["/posts/_like_button.jsx"]
29
- }
30
- }
31
- end
32
-
33
- describe "#include?" do
34
- let(:manifest) { described_class.from_hash(manifest_data) }
35
-
36
- it "returns true for a key present in the manifest" do
37
- expect(manifest.include?("LikeButton")).to be true
38
- end
39
-
40
- it "returns false for a key absent from the manifest" do
41
- expect(manifest.include?("posts/_like_button")).to be false
42
- end
43
- end
44
-
45
- describe "#reference_for with controller_path:" do
46
- let(:shared_only_manifest) { described_class.from_hash(manifest_data) }
47
- let(:dual_manifest) { described_class.from_hash(dual_manifest_data) }
48
-
49
- it "uses shared key when no controller_path given (AC#1)" do
50
- ref = shared_only_manifest.reference_for("LikeButton")
51
- expect(ref.module_id).to eq("/LikeButton.jsx")
52
- end
53
-
54
- it "uses co-located key when it exists in the manifest (AC#2)" do
55
- ref = dual_manifest.reference_for("LikeButton", controller_path: "posts")
56
- expect(ref.module_id).to eq("/posts/_like_button.jsx")
57
- end
58
-
59
- it "co-located takes precedence over shared when both exist (AC#3)" do
60
- ref = dual_manifest.reference_for("LikeButton", controller_path: "posts")
61
- expect(ref.module_id).to eq("/posts/_like_button.jsx")
62
- expect(ref.module_id).not_to eq("/LikeButton.jsx")
63
- end
64
-
65
- it "falls back to shared when co-located key absent (AC#4)" do
66
- ref = dual_manifest.reference_for("LikeButton", controller_path: "articles")
67
- expect(ref.module_id).to eq("/LikeButton.jsx")
68
- end
69
-
70
- it "looks in comments/ first, finds none, uses shared (AC#5)" do
71
- ref = dual_manifest.reference_for("LikeButton", controller_path: "comments")
72
- expect(ref.module_id).to eq("/LikeButton.jsx")
73
- end
74
-
75
- it "returns the same object for repeated calls (dedup by object_id)" do
76
- ref1 = dual_manifest.reference_for("LikeButton", controller_path: "posts")
77
- ref2 = dual_manifest.reference_for("LikeButton", controller_path: "posts")
78
- expect(ref1).to equal(ref2)
79
- end
80
-
81
- it "shared and co-located references are different objects" do
82
- shared = dual_manifest.reference_for("LikeButton")
83
- co_loc = dual_manifest.reference_for("LikeButton", controller_path: "posts")
84
- expect(shared).not_to equal(co_loc)
85
- end
86
- end
87
-
88
- describe ".load" do
89
- let(:loaded_manifest) do
90
- Tempfile.create(["manifest", ".json"]) do |f|
91
- f.write(manifest_data.to_json)
92
- f.flush
93
- described_class.load(f.path)
94
- end
95
- end
96
-
97
- it "returns a frozen manifest (AC#5)" do
98
- expect(loaded_manifest).to be_frozen
99
- end
100
-
101
- it "allows reference_for on a frozen manifest without raising (AC#5)" do
102
- expect { loaded_manifest.reference_for("LikeButton") }.not_to raise_error
103
- end
104
-
105
- it "resolves the correct ClientReference from a frozen manifest (AC#5)" do
106
- ref = loaded_manifest.reference_for("LikeButton")
107
- expect(ref).to be_a(Flight::ClientReference)
108
- expect(ref.module_id).to eq("/LikeButton.jsx")
109
- end
110
-
111
- it "raises ManifestError for unknown component with actionable message (AC#1)" do
112
- expect { loaded_manifest.reference_for("Unknown") }
113
- .to raise_error(Ruact::ManifestError, /Unknown/)
114
- expect { loaded_manifest.reference_for("Unknown") }
115
- .to raise_error(Ruact::ManifestError, /Did you run the Vite build\?/)
116
- end
117
- end
118
-
119
- describe ".from_hash" do
120
- it "returns a mutable manifest (not frozen)" do
121
- manifest = described_class.from_hash(manifest_data)
122
- expect(manifest).not_to be_frozen
123
- end
124
- end
125
-
126
- describe "#contract_for (Story 13.5)", :story_13_5 do
127
- let(:contract) { { "props" => { "postId" => "required" } } }
128
-
129
- it "returns the optional contract Hash when present" do
130
- manifest = described_class.from_hash(
131
- "LikeButton" => manifest_data["LikeButton"].merge("contract" => contract)
132
- )
133
- expect(manifest.contract_for("LikeButton")).to eq(contract)
134
- end
135
-
136
- it "returns nil when the component declares no contract (back-compat)" do
137
- manifest = described_class.from_hash(manifest_data)
138
- expect(manifest.contract_for("LikeButton")).to be_nil
139
- end
140
-
141
- it "returns nil for an unknown component (never raises — fail open)" do
142
- manifest = described_class.from_hash(manifest_data)
143
- expect(manifest.contract_for("Nope")).to be_nil
144
- end
145
-
146
- it "honors the co-located/shared resolve_key precedence" do
147
- data = dual_manifest_data.dup
148
- data["posts/_like_button"] = data["posts/_like_button"].merge("contract" => contract)
149
- manifest = described_class.from_hash(data)
150
- expect(manifest.contract_for("LikeButton", controller_path: "posts")).to eq(contract)
151
- expect(manifest.contract_for("LikeButton")).to be_nil
152
- end
153
-
154
- it "reads cleanly from a frozen manifest" do
155
- manifest = described_class.from_hash(
156
- "LikeButton" => manifest_data["LikeButton"].merge("contract" => contract)
157
- ).freeze
158
- expect(manifest.contract_for("LikeButton")).to eq(contract)
159
- end
160
- end
161
-
162
- describe "#reference_for closest-match suggestion (Story 7.4)" do
163
- let(:shared_only_manifest) { described_class.from_hash(manifest_data) }
164
- let(:dual_manifest) { described_class.from_hash(dual_manifest_data) }
165
-
166
- it "suggests the shared key for a one-character typo" do
167
- expect { shared_only_manifest.reference_for("LikeButtonn") }
168
- .to raise_error(Ruact::ManifestError, /Did you mean "LikeButton"\?/)
169
- end
170
-
171
- it "suggests the shared key for a two-character typo (boundary)" do
172
- # "LiekButtoon" is the AC7 spec 13 boundary case (transposition + one
173
- # insertion); under Damerau-Levenshtein its distance from "LikeButton"
174
- # is 2, so the suggestion fires.
175
- expect { shared_only_manifest.reference_for("LiekButtoon") }
176
- .to raise_error(Ruact::ManifestError, /Did you mean "LikeButton"\?/)
177
- end
178
-
179
- it "falls back to the file-path hint when the typo is over distance 2" do
180
- expect { shared_only_manifest.reference_for("LkkButttn") }
181
- .to raise_error(Ruact::ManifestError) do |error|
182
- expect(error.message)
183
- .to match(%r{Did you mean to add app/javascript/components/LkkButttn\.jsx and rebuild Vite\?})
184
- expect(error.message).not_to match(/Did you mean "LikeButton"\?/)
185
- end
186
- end
187
-
188
- it "falls back to the file-path hint for a totally unrelated name" do
189
- expect { shared_only_manifest.reference_for("Whatever") }
190
- .to raise_error(Ruact::ManifestError,
191
- %r{Did you mean to add app/javascript/components/Whatever\.jsx and rebuild Vite\?})
192
- end
193
-
194
- it "suggests a co-located key (with original key shown) when it is the closest match" do
195
- co_located_only = described_class.from_hash(
196
- "posts/_like_button" => {
197
- "id" => "/posts/_like_button.jsx",
198
- "name" => "default",
199
- "chunks" => ["/posts/_like_button.jsx"]
200
- }
201
- )
202
- expect { co_located_only.reference_for("LikeButtoon") }
203
- .to raise_error(Ruact::ManifestError, %r{Did you mean "posts/_like_button"\?})
204
- end
205
-
206
- it "biases the suggestion toward co-located keys when controller_path is given" do
207
- # Both "LikeButton" (shared) and "posts/_like_button" (co-located) tie
208
- # at distance 1 from "LikeButtoon". Without controller_path the first
209
- # one encountered wins (hash-iteration order); with controller_path:"posts"
210
- # the co-located key is preferred so the suggestion is contextual.
211
- expect { dual_manifest.reference_for("LikeButtoon", controller_path: "posts") }
212
- .to raise_error(Ruact::ManifestError, %r{Did you mean "posts/_like_button"\?})
213
- end
214
-
215
- it "preserves the existing 'Did you run the Vite build?' hint in every variant" do
216
- vite_hint = /Did you run the Vite build\? Run 'npm run build' or start the Vite dev server\./
217
- expect { shared_only_manifest.reference_for("LikeButtonn") }
218
- .to raise_error(Ruact::ManifestError, vite_hint)
219
- expect { shared_only_manifest.reference_for("Whatever") }
220
- .to raise_error(Ruact::ManifestError, vite_hint)
221
- end
222
-
223
- it "uses the AC3 verbatim multi-line 'ruact:' message shape" do
224
- expect { shared_only_manifest.reference_for("LikeButtonn") }
225
- .to raise_error(Ruact::ManifestError) do |error|
226
- expect(error.message).to eq(<<~MSG.strip)
227
- ruact: Component "LikeButtonn" not found in manifest.
228
- Did you mean "LikeButton"?
229
- Did you run the Vite build? Run 'npm run build' or start the Vite dev server.
230
- MSG
231
- end
232
- end
233
- end
234
-
235
- describe "edge cases (Story 7.4)" do
236
- it "does not raise FrozenError when an empty loaded manifest looks up an unknown component" do
237
- empty_manifest = Tempfile.create(["empty_manifest", ".json"]) do |f|
238
- f.write("{}")
239
- f.flush
240
- described_class.load(f.path)
241
- end
242
-
243
- expect(empty_manifest).to be_frozen
244
- expect { empty_manifest.reference_for("LikeButton") }
245
- .to raise_error(Ruact::ManifestError, /not found in manifest/)
246
- end
247
-
248
- it "uses entry['name'] (not the lookup key) for ClientReference#export_name" do
249
- manifest = described_class.from_hash(
250
- "LikeButton" => {
251
- "id" => "/LikeButton.jsx",
252
- "name" => "LikeButton",
253
- "chunks" => ["/LikeButton.jsx"]
254
- },
255
- "posts/_like_button" => {
256
- "id" => "/posts/_like_button.jsx",
257
- "name" => "default",
258
- "chunks" => ["/posts/_like_button.jsx"]
259
- }
260
- )
261
-
262
- shared_ref = manifest.reference_for("LikeButton")
263
- co_located_ref = manifest.reference_for("LikeButton", controller_path: "posts")
264
-
265
- expect(shared_ref.export_name).to eq("LikeButton")
266
- expect(co_located_ref.export_name).to eq("default")
267
- end
268
- end
269
- end
270
- end
@@ -1,119 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
-
5
- module Ruact
6
- RSpec.describe ComponentContract, :story_13_5 do
7
- def validate(prop_names, contract)
8
- described_class.validate(
9
- component_name: "LikeButton", prop_names: prop_names, contract: contract,
10
- at: { file: "app/views/posts/show.html.erb", line: 7, snippet: "<LikeButton ... />" }
11
- )
12
- end
13
-
14
- describe "fail open" do
15
- it "is a no-op when the contract is nil (opt-in)" do
16
- expect { validate(%w[anything goes], nil) }.not_to raise_error
17
- end
18
- end
19
-
20
- describe "missing required prop (AC#1, AC#3)" do
21
- let(:contract) { { "props" => { "postId" => "required", "size" => "optional" } } }
22
-
23
- it "raises ComponentContractError naming the component, file:line, prop, and a fix" do
24
- expect { validate(["size"], contract) }.to raise_error(ComponentContractError) do |e|
25
- expect(e.message).to include("LikeButton")
26
- expect(e.message).to include("app/views/posts/show.html.erb:7")
27
- expect(e.message).to include("postId")
28
- expect(e.message).to include("add the required prop")
29
- end
30
- end
31
-
32
- it "is a PreprocessorError (NFR30 overlay lineage)" do
33
- expect { validate([], contract) }.to raise_error(PreprocessorError)
34
- end
35
-
36
- it "passes when the required prop is present" do
37
- expect { validate(%w[postId size], contract) }.not_to raise_error
38
- end
39
- end
40
-
41
- describe "unknown prop with did-you-mean (AC#1, AC#3)" do
42
- let(:contract) { { "props" => { "postId" => "required", "initialCount" => "optional" } } }
43
-
44
- it "raises with a Damerau-Levenshtein closest-match suggestion for a near typo" do
45
- expect { validate(%w[postId postID], contract) }.to raise_error(ComponentContractError) do |e|
46
- expect(e.message).to include("unknown prop")
47
- expect(e.message).to include("postID")
48
- expect(e.message).to include('did you mean "postId"?')
49
- end
50
- end
51
-
52
- it "raises without a suggestion when nothing is close" do
53
- expect { validate(%w[postId zzzzzzzz], contract) }.to raise_error(ComponentContractError) do |e|
54
- expect(e.message).to include("zzzzzzzz")
55
- expect(e.message).not_to include("did you mean")
56
- end
57
- end
58
-
59
- it "allows undeclared props when passthrough is true" do
60
- open = contract.merge("passthrough" => true)
61
- expect { validate(%w[postId whatever], open) }.not_to raise_error
62
- end
63
-
64
- # Codex review (Patch 1) — a typo OF a required prop (the required name is
65
- # therefore "missing") must surface the did-you-mean, not the less-helpful
66
- # "missing required" message. FR100's canonical postID/postId case.
67
- it "prefers the did-you-mean when the only prop is a typo of a missing required prop" do
68
- expect { validate(%w[postID], contract) }.to raise_error(ComponentContractError) do |e|
69
- expect(e.message).to include("unknown prop")
70
- expect(e.message).to include('did you mean "postId"?')
71
- expect(e.message).not_to include("missing required")
72
- end
73
- end
74
-
75
- it "still reports the missing required prop when the unknown prop is NOT a near typo" do
76
- expect { validate(%w[zzzzzzzz], contract) }.to raise_error(ComponentContractError) do |e|
77
- expect(e.message).to include("missing required prop")
78
- expect(e.message).to include("postId")
79
- end
80
- end
81
- end
82
-
83
- describe "slots (AC#5)" do
84
- let(:contract) do
85
- { "props" => { "title" => "required" }, "slots" => { "header" => "required", "footer" => "optional" } }
86
- end
87
-
88
- it "raises when a required slot is omitted" do
89
- expect { validate(%w[title], contract) }.to raise_error(ComponentContractError) do |e|
90
- expect(e.message).to include("missing required slot")
91
- expect(e.message).to include("header")
92
- end
93
- end
94
-
95
- it "treats a declared slot name as a valid attribute (not unknown)" do
96
- expect { validate(%w[title header], contract) }.not_to raise_error
97
- end
98
-
99
- it "accepts the array form (all optional) and is a no-op when slots are absent" do
100
- arr = { "props" => { "title" => "required" }, "slots" => %w[header footer] }
101
- expect { validate(%w[title], arr) }.not_to raise_error
102
- end
103
-
104
- it "does nothing when the contract declares no slots" do
105
- expect { validate(%w[title], { "props" => { "title" => "required" } }) }.not_to raise_error
106
- end
107
- end
108
-
109
- describe "location formatting" do
110
- it "degrades gracefully when file/line are absent" do
111
- expect do
112
- described_class.validate(
113
- component_name: "X", prop_names: [], contract: { "props" => { "a" => "required" } }
114
- )
115
- end.to raise_error(ComponentContractError, /unknown location/)
116
- end
117
- end
118
- end
119
- end