ruact 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +114 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/ruact/controller.rb +9 -0
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +113 -0
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +2 -2
  11. data/lib/ruact/serializable.rb +98 -6
  12. data/lib/ruact/server.rb +163 -0
  13. data/lib/ruact/server_functions/introspection.rb +81 -0
  14. data/lib/ruact/server_functions.rb +26 -4
  15. data/lib/ruact/testing/component_query.rb +113 -0
  16. data/lib/ruact/testing/flight_extractor.rb +221 -0
  17. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  18. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  19. data/lib/ruact/testing.rb +90 -0
  20. data/lib/ruact/version.rb +1 -1
  21. data/lib/tasks/ruact.rake +55 -2
  22. data/spec/ruact/doctor_spec.rb +141 -0
  23. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  24. data/spec/ruact/install_generator_spec.rb +285 -0
  25. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  26. data/spec/ruact/serializable_spec.rb +126 -0
  27. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  28. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  29. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  30. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  31. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  32. data/spec/support/flight_wire_parser.rb +12 -126
  33. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  34. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  35. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  36. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  38. metadata +14 -2
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require "json"
5
+
6
+ module Ruact
7
+ # Public, host-app-facing test-support surface (FR108, Story 15.4). Loaded
8
+ # ONLY by an explicit `require "ruact/testing"` (or one of the files under
9
+ # `ruact/testing/`) — never by `require "ruact"`, so a production boot carries
10
+ # no RSpec/test dependency. The pure wire parser + structural diff engine live
11
+ # here as the SINGLE shipped implementation shared by both the public
12
+ # `have_ruact_component` matcher and the gem's internal `Ruact::Spec::*`
13
+ # matchers (promoted from `spec/support` in Story 15.4 — wrap, not fork).
14
+ module Testing
15
+ # Raised when {FlightWireParser.parse} encounters input it cannot decode.
16
+ # The message names the byte offset of the unparseable row so the caller
17
+ # can locate the problem in a printed wire string.
18
+ class FlightWireParseError < StandardError; end
19
+
20
+ # Parses a Flight wire byte string into an ordered array of row records.
21
+ #
22
+ # Used by the structural Flight matchers to assert on parsed semantics
23
+ # rather than literal bytes. Pure function — no I/O, no global state, no
24
+ # `Thread.current`.
25
+ #
26
+ # @example
27
+ # wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
28
+ # Ruact::Testing::FlightWireParser.parse(wire)
29
+ # # => [
30
+ # # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
31
+ # # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
32
+ # # ]
33
+ class FlightWireParser
34
+ # Parse a complete Flight wire byte string.
35
+ #
36
+ # @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
37
+ # @return [Array<Hash>] one hash per row, in wire order. See class docs
38
+ # for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
39
+ # @raise [Ruact::Testing::FlightWireParseError] when a row is malformed.
40
+ def self.parse(wire)
41
+ rows = []
42
+ scanner = StringScanner.new(wire)
43
+
44
+ until scanner.eos?
45
+ start_offset = scanner.pos
46
+ rows << parse_row(scanner, start_offset)
47
+ end
48
+
49
+ rows
50
+ end
51
+
52
+ def self.parse_row(scanner, start_offset)
53
+ # Hint rows have no ID: ":H<code><json>\n"
54
+ if scanner.peek(2) == ":H"
55
+ scanner.pos += 2
56
+ code = scanner.getch
57
+ raise_parse_error(start_offset, "missing hint code char") if code.nil?
58
+
59
+ json = read_to_newline(scanner, start_offset)
60
+ return {
61
+ id: nil,
62
+ class: :hint,
63
+ payload: [code, parse_json(json, start_offset)],
64
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
65
+ }
66
+ end
67
+
68
+ hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
69
+ scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
70
+ id = hex.to_i(16)
71
+
72
+ case scanner.peek(1)
73
+ when "I" then parse_tagged(:import, scanner, id, start_offset)
74
+ when "T" then parse_text_row(scanner, id, start_offset)
75
+ when "E" then parse_tagged(:error, scanner, id, start_offset)
76
+ else parse_model_row(scanner, id, start_offset)
77
+ end
78
+ end
79
+
80
+ def self.parse_tagged(klass, scanner, id, start_offset)
81
+ scanner.getch # consume the tag byte (I or E)
82
+ json = read_to_newline(scanner, start_offset)
83
+ {
84
+ id: id,
85
+ class: klass,
86
+ payload: parse_json(json, start_offset),
87
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
88
+ }
89
+ end
90
+
91
+ def self.parse_model_row(scanner, id, start_offset)
92
+ json = read_to_newline(scanner, start_offset)
93
+ {
94
+ id: id,
95
+ class: :model,
96
+ payload: parse_json(json, start_offset),
97
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
98
+ }
99
+ end
100
+
101
+ def self.parse_text_row(scanner, id, start_offset)
102
+ scanner.getch # consume "T"
103
+ len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
104
+ scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
105
+ len = len_hex.to_i(16)
106
+
107
+ text = scanner.peek(len)
108
+ raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
109
+
110
+ scanner.pos += len
111
+ {
112
+ id: id,
113
+ class: :text,
114
+ payload: text,
115
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
116
+ }
117
+ end
118
+
119
+ def self.read_to_newline(scanner, start_offset)
120
+ line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
121
+ line.chomp
122
+ end
123
+
124
+ def self.parse_json(str, offset)
125
+ JSON.parse(str)
126
+ rescue JSON::ParserError => e
127
+ raise_parse_error(offset, "invalid JSON: #{e.message}")
128
+ end
129
+
130
+ def self.raise_parse_error(offset, reason)
131
+ raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
132
+ end
133
+
134
+ private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
135
+ :read_to_newline, :parse_json, :raise_parse_error
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ require_relative "testing/flight_wire_parser"
6
+ require_relative "testing/flight_structure_diff"
7
+ require_relative "testing/flight_extractor"
8
+ require_relative "testing/component_query"
9
+
10
+ # Public, host-app-facing render-assertion helpers (FR108, Story 15.4).
11
+ #
12
+ # Load this EXPLICITLY from your `spec_helper.rb`/`rails_helper.rb`:
13
+ #
14
+ # require "ruact/testing"
15
+ #
16
+ # It is NOT auto-loaded by `require "ruact"` — a production boot never pulls in
17
+ # RSpec. Requiring this file registers the `have_ruact_component` RSpec matcher
18
+ # so request/controller specs can assert a page rendered a given component:
19
+ #
20
+ # expect(response).to have_ruact_component("PostList")
21
+ # expect(response).to have_ruact_component("PostList").with_props(including("posts"))
22
+ # expect(response).not_to have_ruact_component("Admin")
23
+ #
24
+ # `response` may be an ActionDispatch/Rack response (its `.body` is read) or a
25
+ # raw String, in either page shape: a raw `text/x-component` body or an HTML
26
+ # shell embedding `__FLIGHT_DATA`. A `Ruact::Server` function-call/query answer
27
+ # is plain JSON, not Flight — passing it raises a clear error pointing you at
28
+ # `JSON.parse(response.body)` (see the "Testing" docs page).
29
+ #
30
+ # This is a STABLE public API (a conventional test matcher) — it wraps, and
31
+ # does not fork, the internal Story-7.5 structural parser/diff promoted to
32
+ # `Ruact::Testing::FlightWireParser` / `Ruact::Testing::FlightStructureDiff`.
33
+ module Ruact
34
+ module Testing
35
+ # Compares a rendered instance's serialized props against the value passed
36
+ # to `.with_props`. A plain Hash requires exact equality (via `===`, i.e.
37
+ # `==`); an RSpec argument matcher (`hash_including`, `including`, `a_hash_…`)
38
+ # drives subset/fuzzy semantics through its own `#===`.
39
+ def self.props_match?(expected, actual_props)
40
+ # rubocop:disable Style/CaseEquality -- intentional: lets RSpec matchers (hash_including, …) drive semantics via #===.
41
+ expected === actual_props
42
+ # rubocop:enable Style/CaseEquality
43
+ end
44
+ end
45
+ end
46
+
47
+ # rubocop:disable Metrics/BlockLength -- a single cohesive RSpec matcher definition (match + chain + messages).
48
+ RSpec::Matchers.define :have_ruact_component do |name|
49
+ match do |actual|
50
+ @query = Ruact::Testing::ComponentQuery.new(Ruact::Testing::FlightExtractor.extract(actual))
51
+ @instances_props = @query.props_for(name)
52
+ next false if @instances_props.empty?
53
+ next true unless defined?(@expected_props)
54
+
55
+ @instances_props.any? { |props| Ruact::Testing.props_match?(@expected_props, props) }
56
+ end
57
+
58
+ chain :with_props do |expected|
59
+ @expected_props = expected
60
+ end
61
+
62
+ failure_message do |_actual|
63
+ if @instances_props && @instances_props.empty?
64
+ rendered = @query.rendered_names
65
+ found = rendered.empty? ? "no ruact components" : "components: #{rendered.inspect}"
66
+ "expected the response to have rendered a ruact component named #{name.inspect}, " \
67
+ "but found #{found}."
68
+ else
69
+ "expected a rendered #{name.inspect} to have props matching #{@expected_props.inspect}, " \
70
+ "but none did. Rendered instance props (serialized wire form):\n" \
71
+ "#{@instances_props.map(&:inspect).join("\n")}"
72
+ end
73
+ end
74
+
75
+ failure_message_when_negated do |_actual|
76
+ if defined?(@expected_props)
77
+ "expected the response NOT to have a #{name.inspect} with props matching " \
78
+ "#{@expected_props.inspect}, but one did."
79
+ else
80
+ "expected the response NOT to have rendered a ruact component named #{name.inspect}, " \
81
+ "but it did."
82
+ end
83
+ end
84
+
85
+ description do
86
+ base = "have ruact component #{name.inspect}"
87
+ defined?(@expected_props) ? "#{base} with props #{@expected_props.inspect}" : base
88
+ end
89
+ end
90
+ # rubocop:enable Metrics/BlockLength
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.7"
4
+ VERSION = "0.0.8"
5
5
  end
data/lib/tasks/ruact.rake CHANGED
@@ -1,10 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :ruact do
4
- desc "Check ruact installation and configuration (FR27)"
4
+ # Story 15.3 (FR107) `--json` is passed after a `--` separator so neither
5
+ # Rails nor Rake tries to parse it as an option: `bin/rails ruact:doctor -- --json`.
6
+ # Everything after `--` lands in ARGV, which the task scans. A captured local
7
+ # (not a constant) so re-loading this rakefile — e.g. specs that load it into
8
+ # an isolated Rake::Application — does not warn about constant redefinition.
9
+ json_mode = -> { ARGV.include?("--json") }
10
+
11
+ desc "Check ruact installation and configuration (FR27). Append `-- --json` " \
12
+ "for a machine-readable report (EXPERIMENTAL shape — gate on schema_version)."
5
13
  task doctor: :environment do
6
14
  require "ruact/doctor"
7
- exit 1 unless Ruact::Doctor.run
15
+
16
+ if json_mode.call
17
+ require "json"
18
+ report = Ruact::Doctor.new.as_json
19
+ puts JSON.pretty_generate(report)
20
+ exit 1 unless report["status"] == "pass"
21
+ else
22
+ exit 1 unless Ruact::Doctor.run
23
+ end
24
+ end
25
+
26
+ # Story 15.3 (FR107) — read-only introspection of the accessor/route table
27
+ # (the SAME single source of truth codegen consumes) for headless agents & CI.
28
+ # Side-effect-free: it does NOT write the codegen bridge or the TS module.
29
+ # `-- --json` emits the machine-readable document; the bare task prints a
30
+ # compact human table. Exits 1 (message on stderr) on a naming collision,
31
+ # mirroring `ruact:server_functions:generate`.
32
+ desc "Print the ruact accessor/route table. Append `-- --json` for a " \
33
+ "machine-readable document (EXPERIMENTAL shape — gate on schema_version)."
34
+ task routes: :environment do
35
+ require "ruact/server_functions"
36
+
37
+ begin
38
+ Rails.application.routes_reloader.execute_unless_loaded
39
+
40
+ if json_mode.call
41
+ require "json"
42
+ puts JSON.pretty_generate(
43
+ Ruact::ServerFunctions::Introspection.as_json(route_set: Rails.application.routes)
44
+ )
45
+ else
46
+ entries = Ruact::ServerFunctions.introspect(route_set: Rails.application.routes)
47
+ if entries.empty?
48
+ puts "[ruact] no server-function accessors (no Ruact::Server actions or mounted queries)"
49
+ else
50
+ puts "[ruact] server-function accessors:"
51
+ entries.each do |entry|
52
+ puts format(" %-6<verb>s %-28<accessor>s %<path>s",
53
+ verb: entry["http_method"], accessor: entry["js_identifier"], path: entry["path"])
54
+ end
55
+ end
56
+ end
57
+ rescue Ruact::ConfigurationError, Errno::ENOENT, JSON::ParserError => e
58
+ warn "[ruact] error: #{e.message}"
59
+ exit 1
60
+ end
8
61
  end
9
62
 
10
63
  namespace :server_functions do
@@ -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