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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +123 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +16 -6
- 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 -1
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
- 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
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 ``, 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 <LikeButton likes={@likes} /> tag, and the "use client" 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
|
-
# `<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 `{`/`}`: 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 { / }."
|
|
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
|