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
data/lib/ruact/server.rb CHANGED
@@ -104,6 +104,28 @@ module Ruact
104
104
  before_action :__ruact_set_vary_on_accept!
105
105
  after_action :__ruact_set_vary_on_accept!
106
106
 
107
+ # Story 15.0 (F6) — dev-only legibility warning: an action that registers
108
+ # validation errors (`ruact_errors(record)`) and then performs an EXPLICIT
109
+ # render on a function-call request bypasses `default_render`, so the
110
+ # registered errors silently vanish from the JSON body. The warning names
111
+ # the opt-out; it never fires on the documented-correct patterns (Bucket-1
112
+ # page render, redirect on either bucket, the fall-through itself, or an
113
+ # action that never touches the collector). Log-only — bodies and statuses
114
+ # are byte-identical in every environment.
115
+ after_action :__ruact_warn_errors_injection_opt_out!
116
+
117
+ # Story 15.5 (FR109) — dev-only response-shape legibility log: one
118
+ # `[ruact]` line per ruact-NEGOTIATED request naming the chosen bucket +
119
+ # the deciding signal (e.g. `Accept: application/json + POST →
120
+ # function-call JSON`), so the dual-bucket negotiation is observable at
121
+ # runtime without a debugger. Adjacent to the F6 warning above so the two
122
+ # dev-only legibility hooks read as a pair. Same dev gate
123
+ # (`__ruact_development?`), same log-only invariant — bodies, statuses,
124
+ # and headers are byte-identical in every environment. SILENT outside
125
+ # development and on a plain Rails action on the same controller (a
126
+ # response ruact did not negotiate).
127
+ after_action :__ruact_log_response_shape!
128
+
107
129
  # Story 8.4 salvage — same registration order as v1 (Pitfall #1): the
108
130
  # generic StandardError entry first, the explicit
109
131
  # InvalidAuthenticityToken entry second so it wins Rails'
@@ -201,6 +223,12 @@ module Ruact
201
223
  # one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
202
224
  assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
203
225
 
226
+ # Story 15.0 (F6) — reaching this branch means ruact itself is producing
227
+ # the function-call response (fall-through: injection, plain JSON, or
228
+ # 204), so the opt-out warning must stay silent. Set AFTER `view_assigns`
229
+ # is captured above so the flag ivar never leaks into the serialized body.
230
+ @__ruact_function_response_owned = true
231
+
204
232
  # Story 13.3 (FR98, AC3) — when the host opted into the validation-error
205
233
  # round-trip (`ruact_errors(@record)` was called this request), inject the
206
234
  # collected errors under the reserved JSON key `"errors"` alongside the
@@ -249,6 +277,11 @@ module Ruact
249
277
  _ensure_url_is_http_header_safe(location)
250
278
  location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)
251
279
 
280
+ # Story 15.0 (F6) — a Bucket-2 `redirect_to` is a ruact-owned response
281
+ # (`$redirect`; registered errors ride flash), not an injection opt-out.
282
+ @__ruact_function_response_owned = true
283
+ # Story 15.5 (FR109) — mark the `$redirect` sub-shape for the dev log.
284
+ @__ruact_function_redirect = true
252
285
  render json: { "$redirect" => __ruact_redirect_path(location) }
253
286
  end
254
287
 
@@ -290,6 +323,136 @@ module Ruact
290
323
  url
291
324
  end
292
325
 
326
+ # Story 15.0 (F6) — the dev-only opt-out warning. Fires when ALL hold:
327
+ # development environment, the request is a function call
328
+ # ({#__ruact_function_call?}), the collector was touched
329
+ # (`ruact_errors(record)` ran this request), and the response was produced
330
+ # by an EXPLICIT host render — i.e. neither {#default_render}'s
331
+ # function-call branch nor the Bucket-2 {#redirect_to} set
332
+ # `@__ruact_function_response_owned`. In that shape the registered errors
333
+ # were silently dropped from the JSON body (the F6 finding).
334
+ #
335
+ # The four MUST-NOT-warn guardrails fall out of the predicate:
336
+ # (a) Bucket-1 `render :new` — not a function call;
337
+ # (b) `redirect_to` on either bucket — Bucket-2 sets the owned flag,
338
+ # Bucket-1 is not a function call;
339
+ # (c) the fall-through — `default_render` sets the owned flag;
340
+ # (d) an untouched collector — `__ruact_errors_touched?` is false.
341
+ # The exception path never reaches here either: a raise aborts the
342
+ # after_action chain before this hook runs (`rescue_from` renders outside
343
+ # it), so a structured error payload is not misread as an opt-out.
344
+ def __ruact_warn_errors_injection_opt_out!
345
+ return unless __ruact_development?
346
+ return unless __ruact_function_call?
347
+ return unless __ruact_errors_touched?
348
+ return if @__ruact_function_response_owned
349
+
350
+ Rails.logger&.warn(
351
+ "[ruact] #{self.class.name}##{action_name} called `ruact_errors` and then rendered " \
352
+ "explicitly on a function-call request — the registered validation errors were NOT " \
353
+ "injected into the JSON response. Remove the explicit render and let the action fall " \
354
+ "through (ruact injects `errors` into the body), or surface them on a page render with " \
355
+ "`errors={ruact_errors}` / carry them through a `redirect_to` (flash)."
356
+ )
357
+ end
358
+
359
+ # Story 15.5 (FR109) — the dev-only response-shape log. Emits exactly one
360
+ # `[ruact]` line per ruact-negotiated request naming the chosen bucket and
361
+ # the deciding signal (Accept + verb), mirroring the vocabulary of the
362
+ # canonical caller→shape table in `server-actions.md`. Log-only: the
363
+ # response body, status, and headers are byte-identical to before in every
364
+ # environment.
365
+ #
366
+ # Coverage (D1, `Ruact::Server`-only for 15.5): the function-call-JSON
367
+ # bucket (incl. the `204` and `$redirect` sub-shapes) plus, for a page
368
+ # render on a controller that also includes {Ruact::Controller}, the
369
+ # Flight-page and HTML-shell shapes. A GET query (separate dispatch
370
+ # controller) and a page-only controller (includes `Ruact::Controller` but
371
+ # not `Ruact::Server`) are NOT covered — the table documents those shapes;
372
+ # extending the log to them is deferred (see deferred-work.md).
373
+ #
374
+ # SILENT (returns nil message) outside development and on a plain Rails
375
+ # action on the same controller — a response ruact did not negotiate
376
+ # (no function-call Accept and no ruact page render). A raise / before_action
377
+ # halt that skips the after_action chain is an accepted, documented log gap
378
+ # (identical to the F6 warning).
379
+ def __ruact_log_response_shape!
380
+ return unless __ruact_development?
381
+
382
+ message = __ruact_response_shape_log_message
383
+ return unless message
384
+
385
+ Rails.logger&.info(message)
386
+ end
387
+
388
+ # Classify the negotiated response shape from the request signals
389
+ # (`__ruact_function_call?`, Accept, verb) and the ruact-owned outcome flags
390
+ # (`@__ruact_function_redirect` set by the Bucket-2 {#redirect_to};
391
+ # `@__ruact_negotiated_page` set by {Ruact::Controller#emit_ruact_response} /
392
+ # its Flight `redirect_to`). Returns nil when ruact did not negotiate the
393
+ # response, so the log stays silent on plain Rails actions.
394
+ #
395
+ # @return [String, nil] the `[ruact] Ctrl#action — <signal> → <bucket>`
396
+ # line, or nil for a non-negotiated response.
397
+ def __ruact_response_shape_log_message
398
+ shape, signal = __ruact_negotiated_shape_and_signal
399
+ return unless shape
400
+
401
+ "[ruact] #{self.class.name}##{action_name} — #{signal} → #{shape}"
402
+ end
403
+
404
+ # @return [Array(String, String), nil] `[bucket, deciding_signal]` for a
405
+ # ruact-negotiated response, or nil for one ruact did not own.
406
+ def __ruact_negotiated_shape_and_signal
407
+ if __ruact_function_call?
408
+ [__ruact_function_call_shape, "Accept: application/json + #{request.request_method}"]
409
+ else
410
+ __ruact_page_shape_and_signal
411
+ end
412
+ end
413
+
414
+ # The function-call (Bucket-2) sub-shape, distinguished by the ruact-owned
415
+ # outcome: a `$redirect` directive, a `204 No Content` (no exposed ivars),
416
+ # or the ordinary serialized-ivars JSON.
417
+ def __ruact_function_call_shape
418
+ return "function-call $redirect" if @__ruact_function_redirect
419
+ return "function-call JSON (204, no ivars)" if response.status == 204
420
+
421
+ "function-call JSON"
422
+ end
423
+
424
+ # The page-render (Bucket-1 / HTML-shell) shape, keyed on the flag
425
+ # {Ruact::Controller#emit_ruact_response} set when ruact rendered the page.
426
+ # nil for a plain Rails render (the flag was never set) → the log stays
427
+ # silent on a non-negotiated response.
428
+ def __ruact_page_shape_and_signal
429
+ case @__ruact_negotiated_page
430
+ when :flight
431
+ ["Flight page", __ruact_flight_signal]
432
+ when :flight_redirect
433
+ ["Flight redirect", __ruact_flight_signal]
434
+ when :html_shell
435
+ ["HTML shell", "HTML-acceptable Accept, no Flight header"]
436
+ end
437
+ end
438
+
439
+ # The Flight deciding signal, faithful to how the request announced itself:
440
+ # the `Accept: text/x-component` header, or the `Ruact-Request: 1` header.
441
+ def __ruact_flight_signal
442
+ if request.headers["Accept"].to_s.include?("text/x-component")
443
+ "Accept: text/x-component"
444
+ else
445
+ "Ruact-Request: 1"
446
+ end
447
+ end
448
+
449
+ # @return [Boolean] true only in a real Rails development environment
450
+ # (mirrors {Ruact::ManifestResolver.development?}).
451
+ def __ruact_development?
452
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?) &&
453
+ Rails.env.development?
454
+ end
455
+
293
456
  # Raw discriminator — does the request's `Accept` header equal
294
457
  # `application/json`? This is exactly what the 8.1 runtime sends on every
295
458
  # `_makeRef` fetch (Bucket 2 — imperative `await createPost(...)`).
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Story 15.3 (FR107) — the machine-readable `ruact:routes --json` document:
6
+ # a read-only, side-effect-free view of the accessor/route table for headless
7
+ # agents and CI gates. Reuses the SAME collectors codegen consumes (via
8
+ # {Ruact::ServerFunctions.introspect}), so the introspection can never fork a
9
+ # parallel derivation and never writes the codegen bridge / TS module.
10
+ #
11
+ # ## Shape
12
+ #
13
+ # { "schema_version" => Integer,
14
+ # "accessors" => [
15
+ # { "accessor" => String, # the JS identifier (js_identifier)
16
+ # "kind" => String, # "action" | "query"
17
+ # "verb" => String, # HTTP method
18
+ # "path" => String, # cleaned route path
19
+ # "segments" => Array<String>,
20
+ # "params" => [{ "name" => String, "required" => Boolean }] } ] }
21
+ #
22
+ # Queries carry their declared kwargs as `params` (Story 13.4); actions carry
23
+ # their required path segments, uniformly shaped as `{ name, required: true }`.
24
+ # Per Story 15.3 decision D3 there is NO per-accessor component-contract link
25
+ # (accessors are server functions, not components) — the table reports
26
+ # accessors faithfully and omits the "target component contract" clause.
27
+ module Introspection
28
+ # Version of the `ruact:routes --json` document. **EXPERIMENTAL / UNSTABLE**:
29
+ # `0` signals the shape may change without a major version bump while the
30
+ # agent-facing surface is iterated — gate any parser on it. Distinct from
31
+ # {Ruact::Doctor::SCHEMA_VERSION} (a separate, independently-versioned
32
+ # document) and from {Snapshot::VERSION_V2} (the internal codegen bridge).
33
+ SCHEMA_VERSION = 0
34
+
35
+ class << self
36
+ # Builds the `ruact:routes --json` document from +route_set+. The resolver
37
+ # callables default to real constant resolution; specs inject lambdas.
38
+ #
39
+ # @param route_set [#routes] the Rails route set.
40
+ # @param host_predicate [#call, nil] forwarded to {ServerFunctions.introspect}.
41
+ # @param overrides_for [#call, nil] forwarded to {ServerFunctions.introspect}.
42
+ # @param query_class_for [#call, nil] forwarded to {ServerFunctions.introspect}.
43
+ # @return [Hash] the schema-versioned introspection document.
44
+ # @raise [Ruact::ConfigurationError] on any naming collision.
45
+ def as_json(route_set:, host_predicate: nil, overrides_for: nil, query_class_for: nil)
46
+ entries = ServerFunctions.introspect(
47
+ route_set: route_set,
48
+ host_predicate: host_predicate,
49
+ overrides_for: overrides_for,
50
+ query_class_for: query_class_for
51
+ )
52
+ {
53
+ "schema_version" => SCHEMA_VERSION,
54
+ "accessors" => entries.map { |entry| shape(entry) }
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def shape(entry)
61
+ {
62
+ "accessor" => entry["js_identifier"],
63
+ "kind" => entry["kind"],
64
+ "verb" => entry["http_method"],
65
+ "path" => entry["path"],
66
+ "segments" => entry["segments"],
67
+ "params" => params_for(entry)
68
+ }
69
+ end
70
+
71
+ # Queries declare their params as kwargs (Story 13.4 → `params`); actions
72
+ # derive them from required path segments. Uniform `{ name, required }`.
73
+ def params_for(entry)
74
+ return entry["params"] if entry["kind"] == "query"
75
+
76
+ entry["segments"].map { |segment| { "name" => segment, "required" => true } }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -29,6 +29,7 @@ module Ruact
29
29
  autoload :ValidationErrors, "ruact/server_functions/validation_errors"
30
30
  autoload :QueryContext, "ruact/server_functions/query_context"
31
31
  autoload :QueryDispatch, "ruact/server_functions/query_dispatch"
32
+ autoload :Introspection, "ruact/server_functions/introspection"
32
33
 
33
34
  # Story 9.3 / 9.9 — orchestrates the route-driven (v2) codegen. Reads the
34
35
  # route table via {RouteSource} + {QuerySource}, writes the version-2 bridge
@@ -55,10 +56,7 @@ module Ruact
55
56
  # `Rails.logger` when Rails is loaded, else nil.
56
57
  # @return [Array<Hash>] the exposed v2 entries (actions + queries).
57
58
  def self.write_v2_snapshot!(route_set:, root:, logger: default_logger)
58
- actions = RouteSource.collect(route_set)
59
- queries = QuerySource.collect(route_set)
60
- entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
61
- detect_merged_namespace_collisions!(entries)
59
+ entries = introspect(route_set: route_set)
62
60
 
63
61
  json_path = root.join("tmp/cache/ruact/server-functions.json")
64
62
  ts_path = root.join("app/javascript/.ruact/server-functions.ts")
@@ -75,6 +73,30 @@ module Ruact
75
73
  entries
76
74
  end
77
75
 
76
+ # Story 15.3 (FR107) — the read-only, side-effect-free combine point:
77
+ # collect mutation actions ({RouteSource}) + read queries ({QuerySource}),
78
+ # sort by `js_identifier`, and run the merged-namespace collision check —
79
+ # WITHOUT writing the bridge/TS. Both {.write_v2_snapshot!} (codegen) and
80
+ # {Ruact::ServerFunctions::Introspection} (`ruact:routes --json`) call THIS,
81
+ # so what an agent verifies against can never drift from what codegen emits
82
+ # (single source of truth), and a CI gate can introspect without mutating the
83
+ # tree. The resolver callables default to real constant resolution; specs
84
+ # inject lambdas to exercise the derivation without booting controllers.
85
+ #
86
+ # @param route_set [#routes] the Rails route set.
87
+ # @param host_predicate [#call, nil] forwarded to {RouteSource.collect}.
88
+ # @param overrides_for [#call, nil] forwarded to {RouteSource.collect}.
89
+ # @param query_class_for [#call, nil] forwarded to {QuerySource.collect}.
90
+ # @return [Array<Hash>] merged action + query entries (string keys), sorted.
91
+ # @raise [Ruact::ConfigurationError] on any naming collision.
92
+ def self.introspect(route_set:, host_predicate: nil, overrides_for: nil, query_class_for: nil)
93
+ actions = RouteSource.collect(route_set, host_predicate: host_predicate, overrides_for: overrides_for)
94
+ queries = QuerySource.collect(route_set, query_class_for: query_class_for)
95
+ entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
96
+ detect_merged_namespace_collisions!(entries)
97
+ entries
98
+ end
99
+
78
100
  def self.default_logger
79
101
  defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
80
102
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flight_wire_parser"
4
+
5
+ module Ruact
6
+ module Testing
7
+ # Resolves "was component <name> rendered, and with what props?" out of a
8
+ # decoded Flight tree. Pure — no RSpec, no I/O. The public
9
+ # `have_ruact_component` matcher is a thin RSpec wrapper over this.
10
+ #
11
+ # A component is TWO wire rows (Story 7.5 / FR108 semantics):
12
+ #
13
+ # * an `:import` row — `["<module path>", "<export name>", [chunks…]]` —
14
+ # sourced from the client manifest (`reference_for`); its `:id` is the
15
+ # integer the model rows reference; AND
16
+ # * one or more `:model` rows carrying a React element
17
+ # `["$", "$L<hex id>", key, {props}]` whose type references that import.
18
+ #
19
+ # `have_ruact_component("PostList")` matches the import row's DECLARED name
20
+ # (D3): its export name OR its module basename (`/PostList.jsx` → `PostList`)
21
+ # — honest to what the wire actually contains, not the ERB tag or a mapping
22
+ # the wire does not carry. Props are read from the model element's 4th slot
23
+ # in their SERIALIZED wire form (D4: string keys, serialized values).
24
+ class ComponentQuery
25
+ # A React element on the wire is a 4-tuple whose head is the sentinel "$".
26
+ ELEMENT_HEAD = "$"
27
+ # A client-component element's type is "$L" followed by the import's hex id.
28
+ CLIENT_REF = /\A\$L(?<hex>\h+)\z/
29
+
30
+ # @param wire [String] the raw Flight wire byte string.
31
+ def initialize(wire)
32
+ @rows = FlightWireParser.parse(wire)
33
+ @imports = index_imports(@rows)
34
+ @instances = collect_instances(@rows)
35
+ end
36
+
37
+ # @return [Boolean] whether any instance of `name` was rendered.
38
+ def rendered?(name)
39
+ !props_for(name).empty?
40
+ end
41
+
42
+ # @return [Array<Hash>] the serialized props hash of every rendered
43
+ # instance of `name`, in wire order. Empty when `name` was not rendered.
44
+ def props_for(name)
45
+ ids = import_ids_for(name)
46
+ return [] if ids.empty?
47
+
48
+ @instances.select { |inst| ids.include?(inst[:ref_id]) }.map { |inst| inst[:props] }
49
+ end
50
+
51
+ # @return [Array<String>] every distinct component name present in the
52
+ # wire (by declared export name), for a legible failure message.
53
+ def rendered_names
54
+ rendered_ids = @instances.map { |inst| inst[:ref_id] }.uniq
55
+ @imports.slice(*rendered_ids).map { |_, meta| meta[:export_name] }.uniq
56
+ end
57
+
58
+ private
59
+
60
+ def index_imports(rows)
61
+ rows.each_with_object({}) do |row, acc|
62
+ next unless row[:class] == :import
63
+
64
+ payload = row[:payload]
65
+ next unless payload.is_a?(Array)
66
+
67
+ module_path = payload[0]
68
+ export_name = payload[1]
69
+ acc[row[:id]] = {
70
+ module_path: module_path,
71
+ export_name: export_name,
72
+ basename: module_path.is_a?(String) ? File.basename(module_path, ".*") : nil
73
+ }
74
+ end
75
+ end
76
+
77
+ def import_ids_for(name)
78
+ @imports.select do |_id, meta|
79
+ meta[:export_name] == name || meta[:basename] == name
80
+ end.keys
81
+ end
82
+
83
+ # Walk every model row's payload, collecting each client-component element
84
+ # `["$", "$L<hex>", key, props]` as `{ ref_id:, props: }`.
85
+ def collect_instances(rows)
86
+ instances = []
87
+ rows.each do |row|
88
+ next unless row[:class] == :model
89
+
90
+ walk(row[:payload], instances)
91
+ end
92
+ instances
93
+ end
94
+
95
+ def walk(node, instances)
96
+ if element?(node)
97
+ ref = node[1].match(CLIENT_REF)
98
+ instances << { ref_id: ref[:hex].to_i(16), props: node[3] } if ref
99
+ # An element's props/children may themselves contain nested elements.
100
+ walk(node[3], instances)
101
+ elsif node.is_a?(Array)
102
+ node.each { |child| walk(child, instances) }
103
+ elsif node.is_a?(Hash)
104
+ node.each_value { |child| walk(child, instances) }
105
+ end
106
+ end
107
+
108
+ def element?(node)
109
+ node.is_a?(Array) && node.length >= 4 && node[0] == ELEMENT_HEAD && node[1].is_a?(String)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Testing
5
+ # Raised by {FlightExtractor.extract} when the given response/String is not
6
+ # a Flight page render — most importantly when it is a plain-JSON
7
+ # function-call/query response. The message tells the spec author what they
8
+ # were handed and how to assert on it instead. Public: hosts may rescue it.
9
+ class NotAFlightResponseError < StandardError; end
10
+
11
+ # Pulls the Flight wire byte string out of whatever a host request spec
12
+ # hands the `have_ruact_component` matcher: an ActionDispatch/Rack response
13
+ # object (read via `.body`) or a raw String, in either of the two page
14
+ # shapes ruact emits —
15
+ #
16
+ # 1. a raw `text/x-component` body (RSC/navigation request), OR
17
+ # 2. a full HTML document embedding the payload in the `__FLIGHT_DATA`
18
+ # bootstrap `<script>` (a plain browser GET).
19
+ #
20
+ # A `Ruact::Server` function-call/query answer is plain JSON, not Flight —
21
+ # feeding it here raises {NotAFlightResponseError} pointing at `JSON.parse`,
22
+ # rather than silently failing to parse. Pure: no I/O, no global state.
23
+ module FlightExtractor
24
+ # Matches the `<local>.push("<ruby-inspected literal>")` line the
25
+ # `__FLIGHT_DATA` bootstrap script emits (see
26
+ # `Ruact::ViewHelper#ruact_flight_data_script`). ANCHORED to the queue
27
+ # assignment (`self.__FLIGHT_DATA = self.__FLIGHT_DATA || [])`) so an
28
+ # unrelated earlier script on the page (analytics `dataLayer.push(…)`,
29
+ # etc.) can never be mistaken for the Flight payload. The captured group
30
+ # is the full double-quoted Ruby string literal (quotes included),
31
+ # honoring escaped quotes so a `\"` inside the payload does not end it.
32
+ FLIGHT_DATA_PUSH = /
33
+ self\.__FLIGHT_DATA\s*=\s*self\.__FLIGHT_DATA\s*\|\|\s*\[\]\)\s*; # queue assignment
34
+ \s*[A-Za-z_$][\w$]*\.push\( # <local>.push(
35
+ (?<literal>"(?:\\.|[^"\\])*")
36
+ \)\s*;
37
+ /mx
38
+
39
+ # A Flight wire body always starts with a row header: `<hex>:` (id + colon)
40
+ # or a hint row `:H`. JSON bodies start with `{`/`[`/`"`; HTML with `<`.
41
+ FLIGHT_ROW_START = /\A\s*(?:\h+:|:H)/
42
+
43
+ # Reverses `String#inspect` on the captured `__FLIGHT_DATA` literal.
44
+ # `JSON.parse` is unsafe here: Ruby's inspect escapes sequences JSON
45
+ # rejects (`\#` before `{`/`$`/`@`, `\e`, `\a`, …). These are the common
46
+ # single-char escapes; an unrecognized `\c` yields the literal `c`.
47
+ SIMPLE_ESCAPES = {
48
+ "n" => "\n", "t" => "\t", "r" => "\r", "s" => " ", "0" => "\0",
49
+ "a" => "\a", "b" => "\b", "e" => "\e", "f" => "\f", "v" => "\v",
50
+ '"' => '"', "\\" => "\\"
51
+ }.freeze
52
+
53
+ class << self
54
+ # @param response_or_string [#body, String] a response object or a raw
55
+ # body String.
56
+ # @return [String] the extracted Flight wire byte string.
57
+ # @raise [NotAFlightResponseError] when the input is a JSON function-call
58
+ # response, an empty body, or otherwise not a Flight page render.
59
+ def extract(response_or_string)
60
+ body, content_type = read(response_or_string)
61
+ return extract_by_content_type(body, content_type) if content_type
62
+
63
+ extract_by_sniffing(body)
64
+ end
65
+
66
+ private
67
+
68
+ # Reads `[body, content_type]` from a response object or String. A bare
69
+ # String has no content type; a response object may expose one via
70
+ # `content_type`/`media_type` (rack-test, ActionDispatch) — read
71
+ # defensively so any duck-typed `.body` object still works.
72
+ def read(response_or_string)
73
+ return [response_or_string, nil] if response_or_string.is_a?(String)
74
+
75
+ unless response_or_string.respond_to?(:body)
76
+ raise NotAFlightResponseError,
77
+ "have_ruact_component expects a response object (responds to #body) or a String, " \
78
+ "got #{response_or_string.class}."
79
+ end
80
+
81
+ [response_or_string.body.to_s, content_type_of(response_or_string)]
82
+ end
83
+
84
+ def content_type_of(response)
85
+ %i[media_type content_type].each do |m|
86
+ next unless response.respond_to?(m)
87
+
88
+ value = response.public_send(m)
89
+ return value.to_s.split(";").first&.strip unless value.nil? || value.to_s.empty?
90
+ end
91
+ nil
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def extract_by_content_type(body, content_type)
97
+ case content_type
98
+ when "text/x-component"
99
+ ensure_present!(body)
100
+ body
101
+ when "text/html", "application/xhtml+xml"
102
+ from_html(body)
103
+ when "application/json"
104
+ raise_json_error(body)
105
+ else
106
+ # Unknown content type — fall back to sniffing the bytes.
107
+ extract_by_sniffing(body)
108
+ end
109
+ end
110
+
111
+ def extract_by_sniffing(body)
112
+ ensure_present!(body)
113
+
114
+ return from_html(body) if body.include?("__FLIGHT_DATA")
115
+ return body if body.match?(FLIGHT_ROW_START)
116
+ # An HTML document that rendered no component reaches `from_html`,
117
+ # which raises the actionable "no `__FLIGHT_DATA` payload" error.
118
+ return from_html(body) if html_body?(body)
119
+
120
+ raise_json_error(body) if json_body?(body)
121
+
122
+ raise NotAFlightResponseError,
123
+ "have_ruact_component could not find a Flight payload in the response. " \
124
+ "The body is neither a raw `text/x-component` Flight body nor an HTML shell " \
125
+ "embedding `__FLIGHT_DATA`. First bytes: #{body[0, 80].inspect}"
126
+ end
127
+
128
+ def from_html(html)
129
+ match = html.match(FLIGHT_DATA_PUSH)
130
+ unless match
131
+ raise NotAFlightResponseError,
132
+ "have_ruact_component received an HTML document with no `__FLIGHT_DATA` payload. " \
133
+ "This response did not render a ruact component (no Flight data was inlined)."
134
+ end
135
+
136
+ decode_ruby_string_literal(match[:literal])
137
+ end
138
+
139
+ def json_body?(body)
140
+ stripped = body.lstrip
141
+ stripped.start_with?("{", "[", "\"")
142
+ end
143
+
144
+ def html_body?(body)
145
+ stripped = body.lstrip
146
+ stripped.start_with?("<") && /<(?:!doctype\b|html\b|body\b|div\b|head\b)/i.match?(stripped)
147
+ end
148
+
149
+ def raise_json_error(body)
150
+ raise NotAFlightResponseError,
151
+ "have_ruact_component received a plain-JSON response, not a Flight page. " \
152
+ "`Ruact::Server` function-call and query responses answer JSON — assert on them with " \
153
+ "`JSON.parse(response.body)` and a status check, not `have_ruact_component`. " \
154
+ "First bytes: #{body[0, 80].inspect}"
155
+ end
156
+
157
+ def ensure_present!(body)
158
+ return unless body.nil? || body.empty?
159
+
160
+ raise NotAFlightResponseError,
161
+ "have_ruact_component received an empty response body — nothing was rendered. " \
162
+ "(A 204/no-content function-call response has no Flight payload.)"
163
+ end
164
+
165
+ def decode_ruby_string_literal(literal)
166
+ inner = literal[1..-2] # strip surrounding quotes
167
+ out = +""
168
+ pos = 0
169
+ len = inner.length
170
+ while pos < len
171
+ ch = inner[pos]
172
+ if ch == "\\" && pos + 1 < len
173
+ decoded, consumed = decode_escape(inner, pos + 1)
174
+ out << decoded
175
+ pos += 1 + consumed
176
+ else
177
+ out << ch
178
+ pos += 1
179
+ end
180
+ end
181
+ out
182
+ end
183
+
184
+ # Decodes the escape sequence that begins at `inner[pos]` (the char right
185
+ # after the backslash). Returns `[decoded_string, input_chars_consumed]`
186
+ # where `input_chars_consumed` counts only the chars AFTER the
187
+ # backslash, so the caller can advance its cursor precisely.
188
+ def decode_escape(inner, pos)
189
+ ch = inner[pos]
190
+ case ch
191
+ when "u" then decode_unicode(inner, pos)
192
+ when "x" then decode_hex(inner, pos)
193
+ else
194
+ [SIMPLE_ESCAPES.fetch(ch, ch), 1]
195
+ end
196
+ end
197
+
198
+ # `\u{XXXX}` (braced, possibly multiple code points) or `\uXXXX`.
199
+ def decode_unicode(inner, pos)
200
+ if inner[pos + 1] == "{"
201
+ close = inner.index("}", pos + 2) || (return ["u", 1])
202
+ hex = inner[(pos + 2)...close]
203
+ decoded = hex.split.map { |cp| cp.to_i(16) }.pack("U*")
204
+ [decoded, (close - pos) + 1]
205
+ else
206
+ hex = inner[(pos + 1), 4].to_s
207
+ [[hex.to_i(16)].pack("U"), 1 + hex.length]
208
+ end
209
+ end
210
+
211
+ # `\xHH` (one or two hex digits).
212
+ def decode_hex(inner, pos)
213
+ hex = inner[(pos + 1)..].to_s[/\A\h{1,2}/].to_s
214
+ return ["x", 1] if hex.empty?
215
+
216
+ [[hex.to_i(16)].pack("C").force_encoding(inner.encoding), 1 + hex.length]
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end