ruact 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +18 -3
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +120 -1
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +14 -3
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +11 -4
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/controller_spec.rb +7 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +174 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +17 -2
data/spec/ruact/doctor_spec.rb
CHANGED
|
@@ -511,6 +511,141 @@ RSpec.describe Ruact::Doctor do
|
|
|
511
511
|
end
|
|
512
512
|
end
|
|
513
513
|
|
|
514
|
+
# --- JSON introspection (Story 15.3, FR107, AC1 + AC3 + AC4) ---
|
|
515
|
+
|
|
516
|
+
describe "#as_json (machine-readable report)", :story_15_3 do
|
|
517
|
+
subject(:doctor) { described_class.new }
|
|
518
|
+
|
|
519
|
+
context "when all checks pass" do
|
|
520
|
+
before do
|
|
521
|
+
make_manifest
|
|
522
|
+
make_controller(with_include: true)
|
|
523
|
+
make_layout(with_sentinel: true)
|
|
524
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
it "returns a document that round-trips through JSON.parse", :aggregate_failures do
|
|
528
|
+
report = doctor.as_json
|
|
529
|
+
reparsed = JSON.parse(JSON.generate(report))
|
|
530
|
+
expect(reparsed).to eq(report)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
it "gates the document with the EXPERIMENTAL schema_version (0)" do
|
|
534
|
+
expect(doctor.as_json["schema_version"]).to eq(0)
|
|
535
|
+
expect(doctor.as_json["schema_version"]).to eq(Ruact::Doctor::SCHEMA_VERSION)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
it "carries every check with name/status/message/remediation keys", :aggregate_failures do
|
|
539
|
+
checks = doctor.as_json["checks"]
|
|
540
|
+
expect(checks.map { |c| c["name"] }).to eq(described_class::CHECKS.map(&:to_s))
|
|
541
|
+
checks.each do |check|
|
|
542
|
+
expect(check.keys).to contain_exactly("name", "status", "message", "remediation")
|
|
543
|
+
expect(check["status"]).to be_a(String)
|
|
544
|
+
expect(check["message"]).to be_a(String)
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
it "reports top-level status 'pass' and exits-zero semantics" do
|
|
549
|
+
expect(doctor.as_json["status"]).to eq("pass")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it "emits NO prose to stdout (JSON mode is the document only)" do
|
|
553
|
+
expect { doctor.as_json }.not_to output.to_stdout
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
context "when a check fails" do
|
|
558
|
+
before do
|
|
559
|
+
make_controller(with_include: true)
|
|
560
|
+
make_layout(with_sentinel: true)
|
|
561
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
562
|
+
# manifest is missing → check_manifest fails
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
it "reports top-level status 'fail' (non-zero exit semantics)" do
|
|
566
|
+
expect(doctor.as_json["status"]).to eq("fail")
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
it "carries the separate machine-readable remediation for the failing check" do
|
|
570
|
+
manifest = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }
|
|
571
|
+
expect(manifest["status"]).to eq("fail")
|
|
572
|
+
expect(manifest["remediation"]).to eq("Run vite build (or bin/dev) to generate the client manifest.")
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
context "when a check warns (Rack::Deflater mounted)" do
|
|
577
|
+
before do
|
|
578
|
+
make_manifest
|
|
579
|
+
make_controller(with_include: true)
|
|
580
|
+
make_layout(with_sentinel: true)
|
|
581
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
582
|
+
app = Struct.new(:middleware).new([Struct.new(:name).new("Rack::Deflater")])
|
|
583
|
+
allow(Rails).to receive(:application).and_return(app)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
it "keeps status 'pass' — a :warn does not fail — but exposes the warn check" do
|
|
587
|
+
report = doctor.as_json
|
|
588
|
+
expect(report["status"]).to eq("pass")
|
|
589
|
+
flight = report["checks"].find { |c| c["name"] == "flight_middleware" }
|
|
590
|
+
expect(flight["status"]).to eq("warn")
|
|
591
|
+
expect(flight["remediation"]).to include("Exclude text/x-component from compression")
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
it "keeps the message byte-identical to the human tuple (remediation is separate)" do
|
|
596
|
+
_status, human_message = doctor.send(:check_manifest)
|
|
597
|
+
json_message = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }["message"]
|
|
598
|
+
expect(json_message).to eq(human_message)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
describe "#results / #passed? (shared compute, Story 15.3)", :story_15_3 do
|
|
603
|
+
subject(:doctor) { described_class.new }
|
|
604
|
+
|
|
605
|
+
before do
|
|
606
|
+
make_manifest
|
|
607
|
+
make_controller(with_include: true)
|
|
608
|
+
make_layout(with_sentinel: true)
|
|
609
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
it "returns one tuple per check, index-aligned with CHECKS" do
|
|
613
|
+
expect(doctor.results.length).to eq(described_class::CHECKS.length)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
it "passed? is true when all checks pass/warn" do
|
|
617
|
+
expect(doctor.passed?).to be true
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
it "runs each check exactly once (as_json does not double-run check_vite's socket)" do
|
|
621
|
+
# check_vite opens a socket via TCPSocket.new; a single as_json must open it
|
|
622
|
+
# exactly once — proving #results is computed once, not per consumer.
|
|
623
|
+
doctor.as_json
|
|
624
|
+
expect(TCPSocket).to have_received(:new).once
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
describe "#run stays byte-identical after the #results refactor (Story 15.3, AC4a)", :story_15_3 do
|
|
629
|
+
subject(:doctor) { described_class.new }
|
|
630
|
+
|
|
631
|
+
before do
|
|
632
|
+
make_manifest
|
|
633
|
+
make_controller(with_include: true)
|
|
634
|
+
make_layout(with_sentinel: true)
|
|
635
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
it "prints exactly the header + one format_result line per check (no JSON/prose leak), returns true" do
|
|
639
|
+
# Reconstruct the expected output from the SAME format_result the human
|
|
640
|
+
# path uses — proves #run still emits header + one glyph line per check
|
|
641
|
+
# (the optional 3rd remediation element is ignored) and nothing else.
|
|
642
|
+
lines = doctor.results.map { |status, message| doctor.send(:format_result, status, message) }.join("\n")
|
|
643
|
+
expected = "[ruact] Health check\n#{lines}\n"
|
|
644
|
+
|
|
645
|
+
expect { expect(doctor.run).to be true }.to output(expected).to_stdout
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
514
649
|
describe "Rake task definition (Story 5.12)", :story_5_12 do
|
|
515
650
|
# Loads gem/lib/tasks/ruact.rake into a fresh Rake::Application so the
|
|
516
651
|
# task table is isolated from any other spec that may have loaded tasks.
|
|
@@ -549,5 +684,11 @@ RSpec.describe Ruact::Doctor do
|
|
|
549
684
|
# actions is a list of Procs; just assert at least one is attached
|
|
550
685
|
expect(task.actions).not_to be_empty
|
|
551
686
|
end
|
|
687
|
+
|
|
688
|
+
it "defines Rake::Task['ruact:routes'] with an action (Story 15.3)", :story_15_3 do
|
|
689
|
+
task = rake_app.lookup("ruact:routes")
|
|
690
|
+
expect(task).not_to be_nil
|
|
691
|
+
expect(task.actions).not_to be_empty
|
|
692
|
+
end
|
|
552
693
|
end
|
|
553
694
|
end
|
|
@@ -116,6 +116,151 @@ module Ruact
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
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
|
+
|
|
119
264
|
# Story 13.5 (FR100) — preprocess-time component-contract validation, wired
|
|
120
265
|
# through an injectable registry seam (a stub responding to +contract_for+).
|
|
121
266
|
describe "component contract validation", :story_13_5 do
|
|
@@ -359,6 +359,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
359
359
|
gen.create_server_functions_directory
|
|
360
360
|
gen.append_gitignore_entries
|
|
361
361
|
gen.create_vite_config
|
|
362
|
+
gen.create_agents_md
|
|
362
363
|
end.not_to raise_error
|
|
363
364
|
end
|
|
364
365
|
|
|
@@ -371,6 +372,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
371
372
|
expect(File.read(File.join(app_root, ".gitignore")))
|
|
372
373
|
.to include("app/javascript/.ruact/server-functions.ts")
|
|
373
374
|
expect(File).to exist(File.join(app_root, "vite.config.js"))
|
|
375
|
+
expect(File).to exist(File.join(app_root, "AGENTS.md"))
|
|
374
376
|
end
|
|
375
377
|
end
|
|
376
378
|
end
|
|
@@ -889,4 +891,287 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
889
891
|
end
|
|
890
892
|
end
|
|
891
893
|
end
|
|
894
|
+
|
|
895
|
+
# Story 15.1 (FR105) — `ruact:install` emits an AGENTS.md teaching coding
|
|
896
|
+
# agents the ruact conventions, traps, and verification commands. The ruact
|
|
897
|
+
# content is delimited by explicit markers so re-runs are idempotent, a
|
|
898
|
+
# user-authored AGENTS.md is appended to (never clobbered), and --force
|
|
899
|
+
# refreshes ONLY the marked section. Content assertions grep stable tokens
|
|
900
|
+
# (commands, file paths, API names) — never full-file snapshots, since later
|
|
901
|
+
# stories (15.2/15.3/15.4) deliberately evolve the prose.
|
|
902
|
+
describe "install generator — AGENTS.md emission (Story 15.1 — FR105)", :story_15_1 do
|
|
903
|
+
require "stringio"
|
|
904
|
+
require "generators/ruact/install/install_generator"
|
|
905
|
+
|
|
906
|
+
let(:app_root) { Dir.mktmpdir("ruact_install_1501") }
|
|
907
|
+
let(:agents_md_path) { File.join(app_root, "AGENTS.md") }
|
|
908
|
+
let(:begin_marker) { "<!-- ruact:begin -->" }
|
|
909
|
+
let(:end_marker) { "<!-- ruact:end -->" }
|
|
910
|
+
let(:template_path) do
|
|
911
|
+
File.expand_path("../../lib/generators/ruact/install/templates/AGENTS.md.tt", __dir__)
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
after { FileUtils.rm_rf(app_root) }
|
|
915
|
+
|
|
916
|
+
def build_generator(root, opts = {})
|
|
917
|
+
Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def silently
|
|
921
|
+
original = $stdout
|
|
922
|
+
$stdout = StringIO.new
|
|
923
|
+
yield
|
|
924
|
+
ensure
|
|
925
|
+
$stdout = original
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
# Captures and returns the generator's say/say_status stdout for text asserts.
|
|
929
|
+
def capture_stdout
|
|
930
|
+
original = $stdout
|
|
931
|
+
$stdout = StringIO.new
|
|
932
|
+
yield
|
|
933
|
+
$stdout.string
|
|
934
|
+
ensure
|
|
935
|
+
$stdout = original
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
describe "fresh emission (AC#1)" do
|
|
939
|
+
it "creates AGENTS.md delimited by the ruact markers", :aggregate_failures do
|
|
940
|
+
silently { build_generator(app_root).create_agents_md }
|
|
941
|
+
|
|
942
|
+
expect(File).to exist(agents_md_path)
|
|
943
|
+
content = File.read(agents_md_path)
|
|
944
|
+
expect(content).to start_with(begin_marker)
|
|
945
|
+
expect(content.rstrip).to end_with(end_marker)
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
it "covers the model, codegen, queries and verification areas", :aggregate_failures do
|
|
949
|
+
silently { build_generator(app_root).create_agents_md }
|
|
950
|
+
content = File.read(agents_md_path)
|
|
951
|
+
|
|
952
|
+
# The verb rule (mutations = non-GET routed actions on Ruact::Server)
|
|
953
|
+
expect(content).to include("Ruact::Server")
|
|
954
|
+
expect(content).to include("non-GET")
|
|
955
|
+
# Route-driven codegen: ground-truth file + regenerate command
|
|
956
|
+
expect(content).to include("app/javascript/.ruact/server-functions.ts")
|
|
957
|
+
expect(content).to include("bin/rails ruact:server_functions:generate")
|
|
958
|
+
# Queries (reads)
|
|
959
|
+
expect(content).to include("Ruact::Query")
|
|
960
|
+
expect(content).to include("app/queries/")
|
|
961
|
+
expect(content).to include("ruact_queries")
|
|
962
|
+
expect(content).to include("useQuery")
|
|
963
|
+
# Verification commands (the only two that exist today)
|
|
964
|
+
expect(content).to include("bin/rails ruact:doctor")
|
|
965
|
+
# Docs pointers
|
|
966
|
+
expect(content).to include("https://ruact.dev")
|
|
967
|
+
expect(content).to include("llms.txt")
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
it "covers the five traps and the safety areas", :aggregate_failures do
|
|
971
|
+
silently { build_generator(app_root).create_agents_md }
|
|
972
|
+
content = File.read(agents_md_path)
|
|
973
|
+
|
|
974
|
+
# Trap 1 — children unsupported / self-closing only
|
|
975
|
+
expect(content).to include("self-closing")
|
|
976
|
+
expect(content).to include("children")
|
|
977
|
+
# Trap 2 — Ruby (not JS) inside {} props
|
|
978
|
+
expect(content).to include("Ruby, not JavaScript")
|
|
979
|
+
# Trap 3 — Accept-header dual shape (exact application/json)
|
|
980
|
+
expect(content).to include("Accept")
|
|
981
|
+
expect(content).to include("application/json")
|
|
982
|
+
# Trap 4 — ruact_errors fall-through
|
|
983
|
+
expect(content).to include("ruact_errors")
|
|
984
|
+
expect(content).to include("fall-through")
|
|
985
|
+
# Trap 5 — name derivation from routes
|
|
986
|
+
expect(content).to include("createPost")
|
|
987
|
+
expect(content).to include("ruact_function_name")
|
|
988
|
+
# Serialization allowlist
|
|
989
|
+
expect(content).to include("ruact_props")
|
|
990
|
+
expect(content).to include("strict_serialization")
|
|
991
|
+
# SGID helpers (purpose + expiry required grain)
|
|
992
|
+
expect(content).to include("Ruact.signed_global_id")
|
|
993
|
+
expect(content).to include("Ruact.locate_signed")
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
it "makes no claim before its artifact (AC#4 — content honesty)", :aggregate_failures do
|
|
997
|
+
silently { build_generator(app_root).create_agents_md }
|
|
998
|
+
content = File.read(agents_md_path)
|
|
999
|
+
|
|
1000
|
+
# (The loud children `PreprocessorError` is Story 15.2's shipped
|
|
1001
|
+
# artifact, and the `--json` introspection commands are Story 15.3's —
|
|
1002
|
+
# both may now be claimed; see the `:story_15_2` / `:story_15_3` guards
|
|
1003
|
+
# below that PIN each claim's presence. Story 15.4's public render
|
|
1004
|
+
# helpers are now shipped too — see the `:story_15_4` guard below.)
|
|
1005
|
+
#
|
|
1006
|
+
# tsc does not run in a fresh install (no typescript devDep / tsconfig) —
|
|
1007
|
+
# it may only appear conditionally phrased (D1).
|
|
1008
|
+
expect(content).to match(/if your app has typescript tooling/i) if content.include?("tsc")
|
|
1009
|
+
# Volatile strings must not be quoted: no gem version pins.
|
|
1010
|
+
expect(content).not_to match(/\b0\.0\.\d+\b/)
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# Story 15.2 (D2) — now that the loud children error is a shipped artifact,
|
|
1014
|
+
# trap #1 truthfully claims it fails LOUDLY (was "fails silently" in 15.1).
|
|
1015
|
+
it "trap #1 claims the loud PreprocessorError (Story 15.2 D2)", :aggregate_failures, :story_15_2 do
|
|
1016
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1017
|
+
content = File.read(agents_md_path)
|
|
1018
|
+
|
|
1019
|
+
expect(content).to include("PreprocessorError")
|
|
1020
|
+
expect(content).to match(/fail LOUDLY/i)
|
|
1021
|
+
expect(content).not_to match(/fail silently/i)
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# Story 15.3 (D2) — now that the `--json` introspection surface is a shipped
|
|
1025
|
+
# artifact, the "Verify your work" section references it (relaxes the 15.1
|
|
1026
|
+
# honesty guard above). This PINS the claim AND its EXPERIMENTAL framing so
|
|
1027
|
+
# the template never presents the JSON shape as a stable contract.
|
|
1028
|
+
it "references --json introspection as EXPERIMENTAL (15.3 D2)", :aggregate_failures, :story_15_3 do
|
|
1029
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1030
|
+
content = File.read(agents_md_path)
|
|
1031
|
+
|
|
1032
|
+
expect(content).to include("ruact:routes -- --json")
|
|
1033
|
+
expect(content).to include("ruact:doctor")
|
|
1034
|
+
expect(content).to include("Append `-- --json`")
|
|
1035
|
+
expect(content).to match(/EXPERIMENTAL/)
|
|
1036
|
+
expect(content).to include("schema_version")
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Story 15.4 (D6) — now that the public render-assertion helpers are a
|
|
1040
|
+
# shipped artifact (FR108), the "Verify your work" section references them.
|
|
1041
|
+
# This PINS the claim (name + explicit require + the JSON-vs-Flight rule)
|
|
1042
|
+
# so it stays consistent with `website/public/llms.txt`.
|
|
1043
|
+
it "references the ruact/testing render helpers (15.4 D6)", :aggregate_failures, :story_15_4 do
|
|
1044
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1045
|
+
content = File.read(agents_md_path)
|
|
1046
|
+
|
|
1047
|
+
expect(content).to include(%(require "ruact/testing"))
|
|
1048
|
+
expect(content).to include("have_ruact_component")
|
|
1049
|
+
expect(content).to include("JSON.parse(response.body)")
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
describe "idempotency (AC#2 — double-run zero diff)" do
|
|
1054
|
+
it "leaves the file byte-identical on a second run and reports a skip", :aggregate_failures do
|
|
1055
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1056
|
+
first = File.read(agents_md_path)
|
|
1057
|
+
|
|
1058
|
+
output = capture_stdout { build_generator(app_root).create_agents_md }
|
|
1059
|
+
|
|
1060
|
+
expect(File.read(agents_md_path)).to eq(first)
|
|
1061
|
+
expect(output).to include("skip")
|
|
1062
|
+
end
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
describe "append mode (AC#2 — user-authored AGENTS.md)" do
|
|
1066
|
+
let(:user_content) { "# My app\n\nUse pnpm here. Never touch config/secrets.yml.\n" }
|
|
1067
|
+
|
|
1068
|
+
it "appends the marked section preserving every pre-existing user byte", :aggregate_failures do
|
|
1069
|
+
File.write(agents_md_path, user_content)
|
|
1070
|
+
|
|
1071
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1072
|
+
content = File.read(agents_md_path)
|
|
1073
|
+
|
|
1074
|
+
expect(content).to start_with(user_content)
|
|
1075
|
+
expect(content.scan(begin_marker).count).to eq(1)
|
|
1076
|
+
expect(content.scan(end_marker).count).to eq(1)
|
|
1077
|
+
expect(content).to include("\n\n#{begin_marker}")
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
it "blank-line separates the section when the user file lacks a trailing newline" do
|
|
1081
|
+
File.write(agents_md_path, "# My app — no trailing newline")
|
|
1082
|
+
|
|
1083
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1084
|
+
|
|
1085
|
+
expect(File.read(agents_md_path))
|
|
1086
|
+
.to include("# My app — no trailing newline\n\n#{begin_marker}")
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
it "appending then re-running is idempotent (skip, zero diff)" do
|
|
1090
|
+
File.write(agents_md_path, user_content)
|
|
1091
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1092
|
+
appended = File.read(agents_md_path)
|
|
1093
|
+
|
|
1094
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1095
|
+
|
|
1096
|
+
expect(File.read(agents_md_path)).to eq(appended)
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
describe "--force refresh (AC#2 — marked-block-only)" do
|
|
1101
|
+
it "replaces ONLY the between-marker content, never user bytes outside", :aggregate_failures do
|
|
1102
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1103
|
+
section = File.read(agents_md_path)
|
|
1104
|
+
stale = section.sub("## Five traps", "## STALE CONTENT FROM AN OLDER GEM")
|
|
1105
|
+
File.write(agents_md_path, "# mine, above\n\n#{stale}\n# mine, below\n")
|
|
1106
|
+
|
|
1107
|
+
silently { build_generator(app_root, { force: true }).create_agents_md }
|
|
1108
|
+
content = File.read(agents_md_path)
|
|
1109
|
+
|
|
1110
|
+
expect(content).to start_with("# mine, above\n")
|
|
1111
|
+
expect(content).to end_with("# mine, below\n")
|
|
1112
|
+
expect(content).to include("## Five traps")
|
|
1113
|
+
expect(content).not_to include("STALE CONTENT FROM AN OLDER GEM")
|
|
1114
|
+
expect(content.scan(begin_marker).count).to eq(1)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
it "creates the file under --force when none exists" do
|
|
1118
|
+
silently { build_generator(app_root, { force: true }).create_agents_md }
|
|
1119
|
+
expect(File.read(agents_md_path)).to include(begin_marker)
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
# Codex R1 — an incomplete marker state is ambiguous in BOTH directions
|
|
1124
|
+
# (appending would duplicate content next to a stray marker; replacing
|
|
1125
|
+
# would guess the range), so the only byte-safe move is warn + no-op.
|
|
1126
|
+
describe "broken marker pair (byte-safety)" do
|
|
1127
|
+
[
|
|
1128
|
+
["a begin marker without its end marker",
|
|
1129
|
+
"# mine\n\n<!-- ruact:begin -->\ntruncated ruact section, no end marker\n"],
|
|
1130
|
+
["an end marker without its begin marker",
|
|
1131
|
+
"# mine\n\nstray tail of a ruact section\n<!-- ruact:end -->\n"],
|
|
1132
|
+
["an end marker preceding the begin marker",
|
|
1133
|
+
"# mine\n\n<!-- ruact:end -->\nreversed\n<!-- ruact:begin -->\n"],
|
|
1134
|
+
# Codex R2 — a VALID pair plus stray extra markers is still ambiguous:
|
|
1135
|
+
# skip/refresh would leave (or eat past) the unmatched marker.
|
|
1136
|
+
["a stray end marker before a valid pair",
|
|
1137
|
+
"<!-- ruact:end -->\nstray\n\n<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n"],
|
|
1138
|
+
["a stray begin marker after a valid pair",
|
|
1139
|
+
"<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n\nstray\n<!-- ruact:begin -->\n"],
|
|
1140
|
+
["two complete marker pairs",
|
|
1141
|
+
"<!-- ruact:begin -->\none\n<!-- ruact:end -->\n\n<!-- ruact:begin -->\ntwo\n<!-- ruact:end -->\n"]
|
|
1142
|
+
].each do |(label, broken)|
|
|
1143
|
+
it "warns and leaves the file untouched with #{label} (no --force)", :aggregate_failures do
|
|
1144
|
+
File.write(agents_md_path, broken)
|
|
1145
|
+
|
|
1146
|
+
output = capture_stdout { build_generator(app_root).create_agents_md }
|
|
1147
|
+
|
|
1148
|
+
expect(File.read(agents_md_path)).to eq(broken)
|
|
1149
|
+
expect(output).to include("warn")
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
it "warns and leaves the file untouched with #{label} (--force)", :aggregate_failures do
|
|
1153
|
+
File.write(agents_md_path, broken)
|
|
1154
|
+
|
|
1155
|
+
output = capture_stdout { build_generator(app_root, { force: true }).create_agents_md }
|
|
1156
|
+
|
|
1157
|
+
expect(File.read(agents_md_path)).to eq(broken)
|
|
1158
|
+
expect(output).to include("warn")
|
|
1159
|
+
end
|
|
1160
|
+
end
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
describe "action ordering + template budget" do
|
|
1164
|
+
it "defines create_agents_md BEFORE install_javascript_dependencies" do
|
|
1165
|
+
klass = Ruact::Generators::InstallGenerator
|
|
1166
|
+
line = ->(name) { klass.instance_method(name).source_location.last }
|
|
1167
|
+
expect(line.call(:create_agents_md)).to be < line.call(:install_javascript_dependencies)
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# AC#3 tripwire — the ~150-line budget is the epic's scope probe; the
|
|
1171
|
+
# tripwire's tolerance is 160 (the tripwire's, not the product contract's).
|
|
1172
|
+
it "keeps the template body at or under 160 lines" do
|
|
1173
|
+
expect(File.read(template_path).lines.count).to be <= 160
|
|
1174
|
+
end
|
|
1175
|
+
end
|
|
1176
|
+
end
|
|
892
1177
|
end
|