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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  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 +18 -3
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +120 -1
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +149 -0
  11. data/lib/ruact/railtie.rb +14 -3
  12. data/lib/ruact/render_pipeline.rb +8 -1
  13. data/lib/ruact/serializable.rb +98 -6
  14. data/lib/ruact/server.rb +163 -0
  15. data/lib/ruact/server_functions/introspection.rb +81 -0
  16. data/lib/ruact/server_functions.rb +26 -4
  17. data/lib/ruact/testing/component_query.rb +113 -0
  18. data/lib/ruact/testing/flight_extractor.rb +221 -0
  19. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  20. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  21. data/lib/ruact/testing.rb +90 -0
  22. data/lib/ruact/version.rb +1 -1
  23. data/lib/ruact/view_helper.rb +11 -4
  24. data/lib/ruact.rb +1 -0
  25. data/lib/tasks/ruact.rake +55 -2
  26. data/spec/ruact/controller_spec.rb +7 -2
  27. data/spec/ruact/doctor_spec.rb +141 -0
  28. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  29. data/spec/ruact/install_generator_spec.rb +285 -0
  30. data/spec/ruact/manifest_resolver_spec.rb +174 -0
  31. data/spec/ruact/serializable_spec.rb +126 -0
  32. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  33. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  34. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  35. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  36. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  37. data/spec/spec_helper.rb +15 -0
  38. data/spec/support/flight_wire_parser.rb +12 -126
  39. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  40. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  41. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  42. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  43. data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
  44. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  45. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  46. metadata +17 -2
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "active_support/all"
5
+ require "action_dispatch"
6
+ require "ruact/server_functions"
7
+
8
+ module Ruact
9
+ module ServerFunctions
10
+ # Plain query class — only `instance_method(...).parameters` (kwargs) and
11
+ # `.name` (collision origin) are read; no Rails boot needed. Defined at module
12
+ # level (mirrors query_source_spec) so it is not an example-group-leaky
13
+ # constant.
14
+ class CatalogIntrospectQ
15
+ def categories; end
16
+
17
+ def search_users(term:, limit: 10); end
18
+ end
19
+
20
+ # Story 15.3 (FR107) — `ruact:routes --json` introspection. Exercised through
21
+ # a REAL RouteSet carrying both a mutation action and mounted query routes,
22
+ # with the host/query resolvers injected (no controller boot), so the document
23
+ # is proven byte-derived from RouteSource + QuerySource (the same single
24
+ # source of truth codegen consumes) and side-effect-free.
25
+ RSpec.describe Introspection, :story_15_3 do
26
+ let(:query_prefix) { QuerySource::QUERY_CONTROLLER_PREFIX }
27
+
28
+ # A real RouteSet with a mutation action (createPost, updatePost) AND two
29
+ # mounted query GET routes under the query dispatch controller prefix.
30
+ def combined_route_set
31
+ prefix = query_prefix
32
+ rs = ActionDispatch::Routing::RouteSet.new
33
+ rs.draw do
34
+ resources :posts, only: %i[create update]
35
+ get "/q/categories", to: "#{prefix}catalog_introspect_q#categories"
36
+ get "/q/search_users", to: "#{prefix}catalog_introspect_q#search_users"
37
+ end
38
+ rs
39
+ end
40
+
41
+ def as_json(route_set)
42
+ described_class.as_json(
43
+ route_set: route_set,
44
+ host_predicate: ->(_controller) { true },
45
+ query_class_for: lambda { |controller|
46
+ controller.start_with?(query_prefix) ? CatalogIntrospectQ : nil
47
+ }
48
+ )
49
+ end
50
+
51
+ describe ".as_json" do
52
+ subject(:document) { as_json(combined_route_set) }
53
+
54
+ it "gates the document with the EXPERIMENTAL schema_version (0)", :aggregate_failures do
55
+ expect(document["schema_version"]).to eq(0)
56
+ expect(document["schema_version"]).to eq(described_class::SCHEMA_VERSION)
57
+ end
58
+
59
+ it "round-trips through JSON.parse" do
60
+ expect(JSON.parse(JSON.generate(document))).to eq(document)
61
+ end
62
+
63
+ it "reports BOTH action and query accessors sorted by name", :aggregate_failures do
64
+ names = document["accessors"].map { |a| a["accessor"] }
65
+ expect(names).to include("createPost", "updatePost", "categories", "searchUsers")
66
+ expect(names).to eq(names.sort)
67
+ end
68
+
69
+ it "shapes an action with its required path segments as declared params", :aggregate_failures do
70
+ update = document["accessors"].find { |a| a["accessor"] == "updatePost" }
71
+ expect(update["kind"]).to eq("action")
72
+ expect(update["verb"]).to eq("PATCH")
73
+ expect(update["path"]).to eq("/posts/:id")
74
+ expect(update["segments"]).to eq(["id"])
75
+ expect(update["params"]).to eq([{ "name" => "id", "required" => true }])
76
+ end
77
+
78
+ it "shapes a query with its declared kwargs as params (Story 13.4)", :aggregate_failures do
79
+ search = document["accessors"].find { |a| a["accessor"] == "searchUsers" }
80
+ expect(search["kind"]).to eq("query")
81
+ expect(search["verb"]).to eq("GET")
82
+ expect(search["path"]).to eq("/q/search_users")
83
+ expect(search["segments"]).to eq([])
84
+ expect(search["params"]).to eq(
85
+ [{ "name" => "term", "required" => true }, { "name" => "limit", "required" => false }]
86
+ )
87
+ end
88
+
89
+ it "carries exactly the AC2 keys per accessor" do
90
+ document["accessors"].each do |accessor|
91
+ expect(accessor.keys).to contain_exactly("accessor", "kind", "verb", "path", "segments", "params")
92
+ end
93
+ end
94
+
95
+ it "does NOT include a per-accessor component-contract link (D3 = report accessors faithfully)" do
96
+ document["accessors"].each do |accessor|
97
+ expect(accessor).not_to have_key("contract")
98
+ expect(accessor).not_to have_key("has_contract")
99
+ expect(accessor).not_to have_key("contract_exists")
100
+ end
101
+ end
102
+ end
103
+
104
+ describe "side-effect-free (AC2 / AC4d — writes NO bridge or TS)" do
105
+ it "never writes the codegen bridge / TS module", :aggregate_failures do
106
+ allow(SnapshotWriter).to receive(:write_if_changed!)
107
+ allow(Snapshot).to receive(:generate_v2!)
108
+ allow(Codegen).to receive(:generate_ts!)
109
+ allow(File).to receive(:write).and_call_original
110
+
111
+ as_json(combined_route_set)
112
+
113
+ expect(SnapshotWriter).not_to have_received(:write_if_changed!)
114
+ expect(Snapshot).not_to have_received(:generate_v2!)
115
+ expect(Codegen).not_to have_received(:generate_ts!)
116
+ expect(File).not_to have_received(:write)
117
+ end
118
+ end
119
+
120
+ describe "single source of truth (AC2)" do
121
+ it "is byte-derived from ServerFunctions.introspect (same collectors as codegen)" do
122
+ # Both introspection and write_v2_snapshot! flow through .introspect;
123
+ # the document's accessors mirror those entries' identifiers exactly.
124
+ entries = ServerFunctions.introspect(
125
+ route_set: combined_route_set,
126
+ host_predicate: ->(_c) { true },
127
+ query_class_for: ->(c) { c.start_with?(query_prefix) ? CatalogIntrospectQ : nil }
128
+ )
129
+ expect(as_json(combined_route_set)["accessors"].map { |a| a["accessor"] })
130
+ .to eq(entries.map { |e| e["js_identifier"] })
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "rake"
5
+ require "json"
6
+ require "tmpdir"
7
+ require "action_controller"
8
+ require "ruact/server"
9
+ require "ruact/doctor"
10
+ require "ruact/server_functions"
11
+
12
+ # Story 15.3 (FR107) — end-to-end coverage of the `--json` Rake glue itself:
13
+ # the `-- --json` ARGV scan, JSON-only stdout (no human prose), and — for
14
+ # `ruact:doctor` — the preserved process exit code (0 pass/warn, 1 on fail).
15
+ # The `Doctor#as_json` / `Introspection.as_json` shapes are unit-tested
16
+ # elsewhere; THIS proves the task wiring around them (Codex R1, Acceptance
17
+ # Auditor). Mirrors the isolated-Rake harness in
18
+ # spec/ruact/server_functions/rake_spec.rb.
19
+ module Ruact
20
+ RSpec.describe "rake --json introspection tasks", :story_15_3 do
21
+ around do |example|
22
+ Dir.mktmpdir do |dir|
23
+ original_root = Rails.root
24
+ Rails.root = Pathname.new(dir)
25
+
26
+ prev = Rake.application
27
+ Rake.application = Rake::Application.new
28
+ Rake.application.define_task(Rake::Task, :environment)
29
+ load File.expand_path("../../lib/tasks/ruact.rake", __dir__)
30
+
31
+ example.run
32
+ ensure
33
+ Rake.application = prev
34
+ Rails.root = original_root
35
+ end
36
+ end
37
+
38
+ def invoke!(task_name)
39
+ Rake::Task[task_name].reenable
40
+ Rake::Task[task_name].invoke
41
+ end
42
+
43
+ # ---- ruact:doctor -- --json (AC1: JSON-only stdout + preserved exit) ----
44
+
45
+ describe "ruact:doctor -- --json" do
46
+ before { stub_const("ARGV", %w[ruact:doctor -- --json]) }
47
+
48
+ def stub_doctor(status:)
49
+ report = {
50
+ "schema_version" => 0,
51
+ "status" => status,
52
+ "checks" => [{ "name" => "manifest", "status" => status, "message" => "m", "remediation" => nil }]
53
+ }
54
+ allow(Ruact::Doctor).to receive(:new).and_return(instance_double(Ruact::Doctor, as_json: report))
55
+ end
56
+
57
+ it "prints ONLY the JSON document (no prose) and does not exit when all pass", :aggregate_failures do
58
+ stub_doctor(status: "pass")
59
+
60
+ out = nil
61
+ expect { out = capture_stdout_string { invoke!("ruact:doctor") } }.not_to raise_error
62
+ parsed = JSON.parse(out)
63
+ expect(parsed["schema_version"]).to eq(0)
64
+ expect(parsed["status"]).to eq("pass")
65
+ expect(out).not_to include("[ruact] Health check")
66
+ expect(out).not_to include("Run rails generate")
67
+ end
68
+
69
+ it "exits 1 (SystemExit) when a check fails, still emitting the JSON", :aggregate_failures do
70
+ stub_doctor(status: "fail")
71
+
72
+ expect do
73
+ expect { invoke!("ruact:doctor") }.to raise_error(SystemExit) { |e| expect(e.status).to eq(1) }
74
+ end.to output(/"status": "fail"/).to_stdout
75
+ end
76
+ end
77
+
78
+ it "ruact:doctor WITHOUT --json runs the human path (no JSON), returns 0 when healthy" do
79
+ stub_const("ARGV", %w[ruact:doctor])
80
+ allow(Ruact::Doctor).to receive(:run).and_return(true)
81
+
82
+ expect { expect { invoke!("ruact:doctor") }.not_to raise_error }.not_to output(/schema_version/).to_stdout
83
+ expect(Ruact::Doctor).to have_received(:run)
84
+ end
85
+
86
+ # ---- ruact:routes (AC2: JSON via -- --json, human table bare) ----
87
+
88
+ describe "ruact:routes" do
89
+ before do
90
+ stub_const("RakeRoutesPostsController", Class.new(ActionController::Base) { include Ruact::Server })
91
+ reloader = Object.new
92
+ def reloader.execute_unless_loaded; end
93
+ rs = ActionDispatch::Routing::RouteSet.new
94
+ rs.draw { resources :rake_routes_posts, only: %i[create] }
95
+ app = Object.new
96
+ app.define_singleton_method(:routes) { rs }
97
+ app.define_singleton_method(:routes_reloader) { reloader }
98
+ allow(Rails).to receive(:application).and_return(app)
99
+ end
100
+
101
+ it "emits the EXPERIMENTAL JSON document under -- --json (end-to-end, real resolvers)", :aggregate_failures do
102
+ stub_const("ARGV", %w[ruact:routes -- --json])
103
+
104
+ out = capture_stdout_string { invoke!("ruact:routes") }
105
+ parsed = JSON.parse(out)
106
+ expect(parsed["schema_version"]).to eq(0)
107
+ create = parsed["accessors"].find { |a| a["accessor"] == "createRakeRoutesPost" }
108
+ expect(create).to include("kind" => "action", "verb" => "POST", "path" => "/rake_routes_posts")
109
+ end
110
+
111
+ it "prints a compact human table (no JSON) when invoked bare", :aggregate_failures do
112
+ stub_const("ARGV", %w[ruact:routes])
113
+
114
+ out = capture_stdout_string { invoke!("ruact:routes") }
115
+ expect(out).to include("server-function accessors:")
116
+ expect(out).to include("createRakeRoutesPost")
117
+ expect(out).not_to include("schema_version")
118
+ end
119
+
120
+ it "exits 1 with a `[ruact] error:` line on a naming collision" do
121
+ stub_const("ARGV", %w[ruact:routes -- --json])
122
+ allow(Ruact::ServerFunctions).to receive(:introspect)
123
+ .and_raise(Ruact::ConfigurationError, "server-function naming collision: A#x and B#x ...")
124
+
125
+ expect { invoke!("ruact:routes") }
126
+ .to raise_error(SystemExit)
127
+ .and output(/\[ruact\] error:.*naming collision/).to_stderr
128
+ end
129
+ end
130
+
131
+ # Captures $stdout into a String (the harness has no shared helper).
132
+ def capture_stdout_string
133
+ original = $stdout
134
+ $stdout = StringIO.new
135
+ yield
136
+ $stdout.string
137
+ ensure
138
+ $stdout = original
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact/testing"
5
+
6
+ # Story 15.4 (FR108) — end-to-end specs for the PUBLIC render-assertion matcher
7
+ # `have_ruact_component`. Every example asserts against a REAL Flight wire body
8
+ # produced by `Ruact::RenderPipeline` (the same serializer the controller runs),
9
+ # not a hand-authored fixture — so import rows, `$L<id>` references, and the
10
+ # serialized props hash are exactly what a host would receive.
11
+ module Ruact
12
+ RSpec.describe "have_ruact_component matcher", :story_15_4 do
13
+ let(:manifest) do
14
+ ClientManifest.from_hash({
15
+ "LikeButton" => {
16
+ "id" => "/assets/LikeButton-abc.js",
17
+ "name" => "LikeButton",
18
+ "chunks" => ["/assets/LikeButton-abc.js"]
19
+ },
20
+ "PostCard" => {
21
+ "id" => "/components/PostCard.jsx",
22
+ "name" => "PostCard",
23
+ "chunks" => ["/components/PostCard.jsx"]
24
+ }
25
+ })
26
+ end
27
+
28
+ let(:pipeline) { RenderPipeline.new(manifest) }
29
+
30
+ # Renders an ERB snippet to a REAL Flight wire string.
31
+ def render_wire(erb_source, **ivars)
32
+ ctx = Object.new
33
+ ivars.each { |k, v| ctx.instance_variable_set("@#{k}", v) }
34
+ pipeline.render({ erb: erb_source, binding: ctx.instance_eval { binding } }, mode: :string)
35
+ end
36
+
37
+ # Wraps a wire in the REAL HTML shell the gem emits (`__FLIGHT_DATA` push),
38
+ # via the shipped `Ruact::ViewHelper#ruact_flight_data_script`.
39
+ def html_shell(wire)
40
+ emitter = Object.new.extend(Ruact::ViewHelper)
41
+ script = emitter.send(:ruact_flight_data_script, wire)
42
+ "<!DOCTYPE html><html><body><div id=\"root\"></div>#{script}</body></html>"
43
+ end
44
+
45
+ # A minimal duck-typed response object exposing `.body` (+ optional
46
+ # `.content_type`), like an ActionDispatch/Rack response.
47
+ def response_double(body, content_type: nil)
48
+ Struct.new(:body, :content_type).new(body, content_type)
49
+ end
50
+
51
+ describe "presence (raw text/x-component body)" do
52
+ it "passes when the named component was rendered" do
53
+ wire = render_wire("<LikeButton postId={42} />")
54
+ expect(wire).to have_ruact_component("LikeButton")
55
+ end
56
+
57
+ it "resolves the name by module basename too (D3)" do
58
+ wire = render_wire("<PostCard title={\"Hi\"} />")
59
+ # Manifest module path is /components/PostCard.jsx → basename PostCard.
60
+ expect(wire).to have_ruact_component("PostCard")
61
+ end
62
+
63
+ it "fails with a legible message naming what was found when absent" do
64
+ wire = render_wire("<LikeButton />")
65
+ expect do
66
+ expect(wire).to have_ruact_component("Missing")
67
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError,
68
+ /component named "Missing".*found.*LikeButton/m)
69
+ end
70
+
71
+ it "supports negation for an absent component" do
72
+ wire = render_wire("<LikeButton />")
73
+ expect(wire).not_to have_ruact_component("Missing")
74
+ end
75
+
76
+ it "negation fails legibly when the component IS present" do
77
+ wire = render_wire("<LikeButton />")
78
+ expect do
79
+ expect(wire).not_to have_ruact_component("LikeButton")
80
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /NOT to have rendered.*"LikeButton"/m)
81
+ end
82
+ end
83
+
84
+ describe ".with_props (serialized wire form, D4)" do
85
+ it "passes when props satisfy a hash_including matcher" do
86
+ wire = render_wire("<LikeButton postId={42} />")
87
+ expect(wire).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 42))
88
+ end
89
+
90
+ it "passes with a bare key-presence include matcher" do
91
+ wire = render_wire("<LikeButton postId={42} />")
92
+ expect(wire).to have_ruact_component("LikeButton").with_props(including("postId"))
93
+ end
94
+
95
+ it "fails with a legible message when props mismatch" do
96
+ wire = render_wire("<LikeButton postId={42} />")
97
+ expect do
98
+ expect(wire).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 99))
99
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError,
100
+ /props matching.*serialized wire form.*"postId".*42/m)
101
+ end
102
+
103
+ it "supports negation with props" do
104
+ wire = render_wire("<LikeButton postId={42} />")
105
+ expect(wire).not_to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 99))
106
+ end
107
+ end
108
+
109
+ describe "input shapes (D2)" do
110
+ it "accepts a response object exposing #body (raw wire)" do
111
+ wire = render_wire("<LikeButton postId={7} />")
112
+ response = response_double(wire, content_type: "text/x-component")
113
+ expect(response).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 7))
114
+ end
115
+
116
+ it "extracts Flight from an HTML shell embedding __FLIGHT_DATA" do
117
+ wire = render_wire("<LikeButton postId={5} />")
118
+ html = html_shell(wire)
119
+ expect(html).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 5))
120
+ end
121
+
122
+ it "ignores an unrelated earlier .push and anchors to __FLIGHT_DATA" do
123
+ wire = render_wire("<LikeButton postId={5} />")
124
+ shell = html_shell(wire)
125
+ # A realistic layout has other scripts BEFORE the ruact bootstrap.
126
+ noise = %(<script>window.dataLayer=window.dataLayer||[];dataLayer.push("not flight");</script>)
127
+ html = shell.sub("<div id=\"root\"></div>", "<div id=\"root\"></div>#{noise}")
128
+ expect(html).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 5))
129
+ end
130
+
131
+ it "extracts Flight from an HTML-response object (content_type text/html)" do
132
+ wire = render_wire("<LikeButton postId={5} />")
133
+ response = response_double(html_shell(wire), content_type: "text/html; charset=utf-8")
134
+ expect(response).to have_ruact_component("LikeButton")
135
+ end
136
+
137
+ it "raises a clear error on a plain-JSON function-call body" do
138
+ json = response_double(%({"id":1,"title":"Hi"}), content_type: "application/json")
139
+ expect do
140
+ expect(json).to have_ruact_component("PostCard")
141
+ end.to raise_error(Ruact::Testing::NotAFlightResponseError, /plain-JSON response.*JSON\.parse/m)
142
+ end
143
+
144
+ it "raises a clear error on an empty (204) body" do
145
+ empty = response_double("", content_type: nil)
146
+ expect do
147
+ expect(empty).to have_ruact_component("PostCard")
148
+ end.to raise_error(Ruact::Testing::NotAFlightResponseError, /empty response body/m)
149
+ end
150
+
151
+ it "raises a clear error when handed an HTML doc with no Flight payload" do
152
+ html = "<!DOCTYPE html><html><body><h1>Nothing here</h1></body></html>"
153
+ expect do
154
+ expect(html).to have_ruact_component("PostCard")
155
+ end.to raise_error(Ruact::Testing::NotAFlightResponseError, /no `__FLIGHT_DATA` payload/m)
156
+ end
157
+ end
158
+
159
+ describe "escape round-trip through the HTML shell" do
160
+ it "recovers a payload containing quotes and interpolation-like sequences" do
161
+ # Props whose serialized form contains characters Ruby's #inspect
162
+ # escapes (`"`, `#{`, newlines) must survive extraction from the shell.
163
+ wire = render_wire("<LikeButton label={@label} />", label: %(a "quoted" \#{literal}\nvalue))
164
+ html = html_shell(wire)
165
+ expect(html).to have_ruact_component("LikeButton")
166
+ .with_props(a_hash_including("label" => a_string_matching(/quoted/)))
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "English"
5
+ require "shellwords"
6
+
7
+ # Story 15.4 (FR108), AC4c + AC2 — the public test surface must not load in a
8
+ # production boot, and the promoted parser/diff must be the SINGLE shipped
9
+ # implementation the internal `Ruact::Spec` matchers share (wrap, not fork).
10
+ module Ruact
11
+ RSpec.describe "Ruact::Testing load boundary", :story_15_4 do
12
+ it "a bare `require \"ruact\"` does NOT define the public test surface" do
13
+ # Shell out to a pristine Ruby process so nothing this suite already
14
+ # loaded (spec/support requires `ruact/testing/*`) pollutes the check.
15
+ lib = File.expand_path("../../../lib", __dir__)
16
+ script = <<~RUBY
17
+ $LOAD_PATH.unshift(#{lib.inspect})
18
+ require "ruact"
19
+ defined = Ruact.const_defined?(:Testing, false)
20
+ # `have_ruact_component` must not be registered on the RSpec matcher surface either.
21
+ rspec_loaded = $LOADED_FEATURES.any? { |f| f.include?("rspec/expectations") }
22
+ print [defined, rspec_loaded].inspect
23
+ RUBY
24
+
25
+ output = `#{RbConfig.ruby} -e #{Shellwords.escape(script)} 2>&1`
26
+ expect($CHILD_STATUS).to be_success, "subprocess failed: #{output}"
27
+ expect(output).to eq("[false, false]"),
28
+ "expected `require \"ruact\"` to leave Ruact::Testing undefined and " \
29
+ "RSpec unloaded, got: #{output}"
30
+ end
31
+
32
+ it "shares ONE implementation with the internal matchers (no fork)" do
33
+ require "ruact/testing"
34
+ require_relative "../../support/flight_wire_parser"
35
+ require_relative "../../support/matchers/flight_fixture_matcher"
36
+
37
+ expect(Ruact::Spec::FlightWireParser).to equal(Ruact::Testing::FlightWireParser)
38
+ expect(Ruact::Spec::FlightStructureDiff).to equal(Ruact::Testing::FlightStructureDiff)
39
+ end
40
+ end
41
+ end
data/spec/spec_helper.rb CHANGED
@@ -58,5 +58,20 @@ RSpec.configure do |config|
58
58
  # before every example so no leaked double survives — examples that need a
59
59
  # specific logger set their own in their own `before` (which runs after this).
60
60
  Rails.logger = Logger.new(IO::NULL) if defined?(Rails) && Rails.respond_to?(:logger=)
61
+
62
+ # Manifest-over-HTTP fix: the gem suite runs with `Rails.env == development`
63
+ # by default, where `Ruact::ManifestResolver` would otherwise fetch the live
64
+ # manifest from the Vite dev server (localhost:5173) on every ActionView
65
+ # render — making the suite hit the network and go flaky on a dev machine
66
+ # that happens to run a foreign Vite there. Pin the pre-fix behaviour (return
67
+ # the boot-loaded `Ruact.manifest`) so the whole suite is hermetic. The
68
+ # resolver's own spec re-stubs these `.and_call_original` to exercise the real
69
+ # HTTP/fallback paths with `Net::HTTP` mocked. (The programmatic
70
+ # `RenderPipeline#render({erb:})` path passes `registry: nil` and never
71
+ # touches the resolver, so the benchmark measures the real render cost.)
72
+ if defined?(Ruact::ManifestResolver)
73
+ allow(Ruact::ManifestResolver).to receive(:resolve) { Ruact.manifest }
74
+ allow(Ruact::ManifestResolver).to receive(:resolve_soft) { Ruact.manifest }
75
+ end
61
76
  end
62
77
  end
@@ -1,135 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "strscan"
4
- require "json"
3
+ # Story 15.4 (FR108) — the pure Flight wire parser was PROMOTED onto the gem's
4
+ # `lib/` load path as `Ruact::Testing::FlightWireParser` so the new public
5
+ # `have_ruact_component` matcher and the gem's own internal matchers share ONE
6
+ # implementation (wrap, not fork). This file is now a thin shim: it loads the
7
+ # promoted implementation and re-exports it under the historical test-only
8
+ # `Ruact::Spec` namespace so the existing internal matcher wrappers and their
9
+ # self-tests keep working unchanged.
10
+ require "ruact/testing/flight_wire_parser"
5
11
 
6
12
  module Ruact
7
13
  # Test-support utilities. Code under `Ruact::Spec` is consumed only by the
8
14
  # gem's own RSpec suite; it is not part of the public API and may change
9
- # shape across stories without a deprecation cycle.
15
+ # shape across stories without a deprecation cycle. The parser/diff it names
16
+ # are aliases for the shipped `Ruact::Testing::*` implementation.
10
17
  module Spec
11
- # Raised when {FlightWireParser.parse} encounters input it cannot decode.
12
- # The message names the byte offset of the unparseable row so the spec
13
- # author can locate the problem in a printed wire string.
14
- class FlightWireParseError < StandardError; end
15
-
16
- # Parses a Flight wire byte string into an ordered array of row records.
17
- #
18
- # Used by the structural Flight RSpec matchers
19
- # (`match_flight_structure` / `include_flight_row`) to assert on parsed
20
- # semantics rather than literal bytes. Pure function — no I/O, no global
21
- # state, no `Thread.current`.
22
- #
23
- # @example
24
- # wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
25
- # Ruact::Spec::FlightWireParser.parse(wire)
26
- # # => [
27
- # # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
28
- # # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
29
- # # ]
30
- class FlightWireParser
31
- # Parse a complete Flight wire byte string.
32
- #
33
- # @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
34
- # @return [Array<Hash>] one hash per row, in wire order. See class docs
35
- # for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
36
- # @raise [Ruact::Spec::FlightWireParseError] when a row is malformed.
37
- def self.parse(wire)
38
- rows = []
39
- scanner = StringScanner.new(wire)
40
-
41
- until scanner.eos?
42
- start_offset = scanner.pos
43
- rows << parse_row(scanner, start_offset)
44
- end
45
-
46
- rows
47
- end
48
-
49
- def self.parse_row(scanner, start_offset)
50
- # Hint rows have no ID: ":H<code><json>\n"
51
- if scanner.peek(2) == ":H"
52
- scanner.pos += 2
53
- code = scanner.getch
54
- raise_parse_error(start_offset, "missing hint code char") if code.nil?
55
-
56
- json = read_to_newline(scanner, start_offset)
57
- return {
58
- id: nil,
59
- class: :hint,
60
- payload: [code, parse_json(json, start_offset)],
61
- raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
62
- }
63
- end
64
-
65
- hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
66
- scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
67
- id = hex.to_i(16)
68
-
69
- case scanner.peek(1)
70
- when "I" then parse_tagged(:import, scanner, id, start_offset)
71
- when "T" then parse_text_row(scanner, id, start_offset)
72
- when "E" then parse_tagged(:error, scanner, id, start_offset)
73
- else parse_model_row(scanner, id, start_offset)
74
- end
75
- end
76
-
77
- def self.parse_tagged(klass, scanner, id, start_offset)
78
- scanner.getch # consume the tag byte (I or E)
79
- json = read_to_newline(scanner, start_offset)
80
- {
81
- id: id,
82
- class: klass,
83
- payload: parse_json(json, start_offset),
84
- raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
85
- }
86
- end
87
-
88
- def self.parse_model_row(scanner, id, start_offset)
89
- json = read_to_newline(scanner, start_offset)
90
- {
91
- id: id,
92
- class: :model,
93
- payload: parse_json(json, start_offset),
94
- raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
95
- }
96
- end
97
-
98
- def self.parse_text_row(scanner, id, start_offset)
99
- scanner.getch # consume "T"
100
- len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
101
- scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
102
- len = len_hex.to_i(16)
103
-
104
- text = scanner.peek(len)
105
- raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
106
-
107
- scanner.pos += len
108
- {
109
- id: id,
110
- class: :text,
111
- payload: text,
112
- raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
113
- }
114
- end
115
-
116
- def self.read_to_newline(scanner, start_offset)
117
- line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
118
- line.chomp
119
- end
120
-
121
- def self.parse_json(str, offset)
122
- JSON.parse(str)
123
- rescue JSON::ParserError => e
124
- raise_parse_error(offset, "invalid JSON: #{e.message}")
125
- end
126
-
127
- def self.raise_parse_error(offset, reason)
128
- raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
129
- end
130
-
131
- private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
132
- :read_to_newline, :parse_json, :raise_parse_error
133
- end
18
+ FlightWireParser = Ruact::Testing::FlightWireParser
19
+ FlightWireParseError = Ruact::Testing::FlightWireParseError
134
20
  end
135
21
  end