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
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|