ruact 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +18 -3
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +120 -1
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +14 -3
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +11 -4
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/controller_spec.rb +7 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +174 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +17 -2
data/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
|
-
|
|
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
|