ruact 0.0.2 → 0.0.4
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
# Test-support utilities. Code under `Ruact::Spec` is consumed only by the
|
|
8
|
+
# gem's own RSpec suite; it is not part of the public API and may change
|
|
9
|
+
# shape across stories without a deprecation cycle.
|
|
10
|
+
module Spec
|
|
11
|
+
# Raised when {FlightWireParser.parse} encounters input it cannot decode.
|
|
12
|
+
# The message names the byte offset of the unparseable row so the spec
|
|
13
|
+
# author can locate the problem in a printed wire string.
|
|
14
|
+
class FlightWireParseError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Parses a Flight wire byte string into an ordered array of row records.
|
|
17
|
+
#
|
|
18
|
+
# Used by the structural Flight RSpec matchers
|
|
19
|
+
# (`match_flight_structure` / `include_flight_row`) to assert on parsed
|
|
20
|
+
# semantics rather than literal bytes. Pure function — no I/O, no global
|
|
21
|
+
# state, no `Thread.current`.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
|
|
25
|
+
# Ruact::Spec::FlightWireParser.parse(wire)
|
|
26
|
+
# # => [
|
|
27
|
+
# # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
|
|
28
|
+
# # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
|
|
29
|
+
# # ]
|
|
30
|
+
class FlightWireParser
|
|
31
|
+
# Parse a complete Flight wire byte string.
|
|
32
|
+
#
|
|
33
|
+
# @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
|
|
34
|
+
# @return [Array<Hash>] one hash per row, in wire order. See class docs
|
|
35
|
+
# for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
|
|
36
|
+
# @raise [Ruact::Spec::FlightWireParseError] when a row is malformed.
|
|
37
|
+
def self.parse(wire)
|
|
38
|
+
rows = []
|
|
39
|
+
scanner = StringScanner.new(wire)
|
|
40
|
+
|
|
41
|
+
until scanner.eos?
|
|
42
|
+
start_offset = scanner.pos
|
|
43
|
+
rows << parse_row(scanner, start_offset)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rows
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse_row(scanner, start_offset)
|
|
50
|
+
# Hint rows have no ID: ":H<code><json>\n"
|
|
51
|
+
if scanner.peek(2) == ":H"
|
|
52
|
+
scanner.pos += 2
|
|
53
|
+
code = scanner.getch
|
|
54
|
+
raise_parse_error(start_offset, "missing hint code char") if code.nil?
|
|
55
|
+
|
|
56
|
+
json = read_to_newline(scanner, start_offset)
|
|
57
|
+
return {
|
|
58
|
+
id: nil,
|
|
59
|
+
class: :hint,
|
|
60
|
+
payload: [code, parse_json(json, start_offset)],
|
|
61
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
|
|
66
|
+
scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
|
|
67
|
+
id = hex.to_i(16)
|
|
68
|
+
|
|
69
|
+
case scanner.peek(1)
|
|
70
|
+
when "I" then parse_tagged(:import, scanner, id, start_offset)
|
|
71
|
+
when "T" then parse_text_row(scanner, id, start_offset)
|
|
72
|
+
when "E" then parse_tagged(:error, scanner, id, start_offset)
|
|
73
|
+
else parse_model_row(scanner, id, start_offset)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.parse_tagged(klass, scanner, id, start_offset)
|
|
78
|
+
scanner.getch # consume the tag byte (I or E)
|
|
79
|
+
json = read_to_newline(scanner, start_offset)
|
|
80
|
+
{
|
|
81
|
+
id: id,
|
|
82
|
+
class: klass,
|
|
83
|
+
payload: parse_json(json, start_offset),
|
|
84
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.parse_model_row(scanner, id, start_offset)
|
|
89
|
+
json = read_to_newline(scanner, start_offset)
|
|
90
|
+
{
|
|
91
|
+
id: id,
|
|
92
|
+
class: :model,
|
|
93
|
+
payload: parse_json(json, start_offset),
|
|
94
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.parse_text_row(scanner, id, start_offset)
|
|
99
|
+
scanner.getch # consume "T"
|
|
100
|
+
len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
|
|
101
|
+
scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
|
|
102
|
+
len = len_hex.to_i(16)
|
|
103
|
+
|
|
104
|
+
text = scanner.peek(len)
|
|
105
|
+
raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
|
|
106
|
+
|
|
107
|
+
scanner.pos += len
|
|
108
|
+
{
|
|
109
|
+
id: id,
|
|
110
|
+
class: :text,
|
|
111
|
+
payload: text,
|
|
112
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.read_to_newline(scanner, start_offset)
|
|
117
|
+
line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
|
|
118
|
+
line.chomp
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.parse_json(str, offset)
|
|
122
|
+
JSON.parse(str)
|
|
123
|
+
rescue JSON::ParserError => e
|
|
124
|
+
raise_parse_error(offset, "invalid JSON: #{e.message}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.raise_parse_error(offset, reason)
|
|
128
|
+
raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
|
|
132
|
+
:read_to_newline, :parse_json, :raise_parse_error
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require_relative "flight_wire_parser"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module Spec
|
|
8
|
+
RSpec.describe FlightWireParser do
|
|
9
|
+
describe ".parse" do
|
|
10
|
+
it "parses a single model row" do
|
|
11
|
+
rows = described_class.parse(%(0:{"className":"box"}\n))
|
|
12
|
+
|
|
13
|
+
expect(rows).to eq([
|
|
14
|
+
{
|
|
15
|
+
id: 0,
|
|
16
|
+
class: :model,
|
|
17
|
+
payload: { "className" => "box" },
|
|
18
|
+
raw: %(0:{"className":"box"}\n)
|
|
19
|
+
}
|
|
20
|
+
])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "parses a single import row and decodes the hex id" do
|
|
24
|
+
rows = described_class.parse(%(a:I["/L.jsx","L",["/L.jsx"]]\n))
|
|
25
|
+
|
|
26
|
+
expect(rows.length).to eq(1)
|
|
27
|
+
expect(rows.first).to include(
|
|
28
|
+
id: 10,
|
|
29
|
+
class: :import,
|
|
30
|
+
payload: ["/L.jsx", "L", ["/L.jsx"]]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "parses a single error row" do
|
|
35
|
+
rows = described_class.parse(%(2:E{"message":"boom"}\n))
|
|
36
|
+
|
|
37
|
+
expect(rows.first).to include(
|
|
38
|
+
id: 2,
|
|
39
|
+
class: :error,
|
|
40
|
+
payload: { "message" => "boom" }
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "parses a hint row with no id" do
|
|
45
|
+
rows = described_class.parse(%(:HL"/preload.js"\n))
|
|
46
|
+
|
|
47
|
+
expect(rows.first).to include(
|
|
48
|
+
id: nil,
|
|
49
|
+
class: :hint,
|
|
50
|
+
payload: ["L", "/preload.js"]
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "parses a T row of exactly LARGE_TEXT_THRESHOLD bytes followed by a model row that references it" do
|
|
55
|
+
large_text = "a" * 1024
|
|
56
|
+
wire = "1:T#{1024.to_s(16)},#{large_text}0:\"$T1\"\n"
|
|
57
|
+
|
|
58
|
+
rows = described_class.parse(wire)
|
|
59
|
+
|
|
60
|
+
expect(rows.length).to eq(2)
|
|
61
|
+
expect(rows[0]).to include(id: 1, class: :text, payload: large_text)
|
|
62
|
+
expect(rows[1]).to include(id: 0, class: :model, payload: "$T1")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "parses a mixed-row sequence preserving wire order" do
|
|
66
|
+
wire = %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{}]\n)
|
|
67
|
+
|
|
68
|
+
rows = described_class.parse(wire)
|
|
69
|
+
|
|
70
|
+
expect(rows.map { |r| [r[:id], r[:class]] }).to eq([[1, :import], [0, :model]])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "parses empty input as an empty array" do
|
|
74
|
+
expect(described_class.parse("")).to eq([])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "raises FlightWireParseError naming the byte offset on malformed JSON" do
|
|
78
|
+
wire = %(0:{not-json}\n)
|
|
79
|
+
|
|
80
|
+
expect { described_class.parse(wire) }
|
|
81
|
+
.to raise_error(FlightWireParseError, /cannot parse row at offset \d+/)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "raises FlightWireParseError when a T row is truncated" do
|
|
85
|
+
wire = "0:T#{10.to_s(16)},abc" # claims 16 bytes but provides 3
|
|
86
|
+
|
|
87
|
+
expect { described_class.parse(wire) }
|
|
88
|
+
.to raise_error(FlightWireParseError, /cannot parse row at offset 0: T row truncated/)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -1,5 +1,278 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../flight_wire_parser"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
module Spec
|
|
7
|
+
# Internal helpers shared by the structural Flight matchers. Lives under
|
|
8
|
+
# `Ruact::Spec` (test-only namespace) — no top-level constants leak into
|
|
9
|
+
# the gem's public API surface.
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- single cohesive helper for matcher diffing/formatting; splitting would scatter related logic across files.
|
|
11
|
+
class FlightStructureDiff
|
|
12
|
+
# Keys the parser produces on every row. Predicates and expected rows
|
|
13
|
+
# may only reference these keys; unknown keys raise upfront so typos
|
|
14
|
+
# like `payloed:` don't silently match every row via `nil == nil`.
|
|
15
|
+
KNOWN_ROW_KEYS = %i[id class payload raw].freeze
|
|
16
|
+
|
|
17
|
+
# Keys that must be present in every expected-row hash passed to
|
|
18
|
+
# `match_flight_structure`. Excludes `:raw` because expected rows are
|
|
19
|
+
# authored as semantic descriptions, not byte-level snapshots.
|
|
20
|
+
REQUIRED_EXPECTED_KEYS = %i[id class payload].freeze
|
|
21
|
+
|
|
22
|
+
# Compute the set of differences between actual parsed rows and the
|
|
23
|
+
# expected structure. Import rows are matched as a multiset (their
|
|
24
|
+
# relative order among each other is not significant per AC1);
|
|
25
|
+
# everything else compares positionally.
|
|
26
|
+
def self.compute(actual_rows, expected_rows)
|
|
27
|
+
validate_expected_rows!(expected_rows)
|
|
28
|
+
return diffs_for_length_mismatch(actual_rows, expected_rows) if actual_rows.length != expected_rows.length
|
|
29
|
+
|
|
30
|
+
diffs = []
|
|
31
|
+
pending_actual_imports = []
|
|
32
|
+
pending_expected_imports = []
|
|
33
|
+
|
|
34
|
+
actual_rows.zip(expected_rows).each_with_index do |(actual, expected), i|
|
|
35
|
+
if actual[:class] == :import && expected[:class] == :import
|
|
36
|
+
pending_actual_imports << [i, actual]
|
|
37
|
+
pending_expected_imports << [i, expected]
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
next if rows_equal?(actual, expected)
|
|
42
|
+
|
|
43
|
+
diffs << build_field_diff(i, actual, expected)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
diffs.concat(diff_imports_unordered(pending_actual_imports, pending_expected_imports))
|
|
47
|
+
diffs.sort_by { |d| d[:idx] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Imports are an unordered multiset within their class (AC1). Sort both
|
|
51
|
+
# sides by `:id` (always unique per render) and any leftover diffs come
|
|
52
|
+
# from semantic mismatch in the same-position-after-sort pair.
|
|
53
|
+
def self.diff_imports_unordered(actual_pairs, expected_pairs)
|
|
54
|
+
return [] if actual_pairs.empty? && expected_pairs.empty?
|
|
55
|
+
|
|
56
|
+
sort_key = ->(pair) { [pair[1][:id].to_i, pair[1][:payload].to_s] }
|
|
57
|
+
sorted_actual = actual_pairs.sort_by(&sort_key)
|
|
58
|
+
sorted_expected = expected_pairs.sort_by(&sort_key)
|
|
59
|
+
|
|
60
|
+
sorted_actual.zip(sorted_expected).filter_map do |(act_idx, act_row), (_exp_idx, exp_row)|
|
|
61
|
+
next if rows_equal?(act_row, exp_row)
|
|
62
|
+
|
|
63
|
+
build_field_diff(act_idx, act_row, exp_row)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.diffs_for_length_mismatch(actual_rows, expected_rows)
|
|
68
|
+
diffs = []
|
|
69
|
+
[actual_rows.length, expected_rows.length].max.times do |i|
|
|
70
|
+
actual = actual_rows[i]
|
|
71
|
+
expected = expected_rows[i]
|
|
72
|
+
if actual.nil?
|
|
73
|
+
diffs << { kind: :missing, idx: i, expected_row: expected }
|
|
74
|
+
elsif expected.nil?
|
|
75
|
+
diffs << { kind: :extra, idx: i, actual_row: actual }
|
|
76
|
+
elsif !rows_equal?(actual, expected)
|
|
77
|
+
diffs << build_field_diff(i, actual, expected)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
diffs
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.rows_equal?(actual, expected)
|
|
84
|
+
REQUIRED_EXPECTED_KEYS.all? { |k| actual[k] == expected[k] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.build_field_diff(idx, actual, expected)
|
|
88
|
+
field_diff = nil
|
|
89
|
+
REQUIRED_EXPECTED_KEYS.each do |key|
|
|
90
|
+
next if actual[key] == expected[key]
|
|
91
|
+
|
|
92
|
+
path, sub_expected, sub_actual = first_difference(expected[key], actual[key], ".#{key}")
|
|
93
|
+
field_diff = { path: path, expected: sub_expected, got: sub_actual }
|
|
94
|
+
break
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
kind: :differs,
|
|
99
|
+
idx: idx,
|
|
100
|
+
row_class: actual[:class],
|
|
101
|
+
path: field_diff[:path],
|
|
102
|
+
expected: field_diff[:expected],
|
|
103
|
+
got: field_diff[:got],
|
|
104
|
+
expected_row: expected,
|
|
105
|
+
got_row: actual
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Walks parallel structures (Hash/Array/scalar) and returns the path,
|
|
110
|
+
# expected leaf, and actual leaf at the first differing position.
|
|
111
|
+
def self.first_difference(expected, actual, path)
|
|
112
|
+
return [path, expected, actual] if expected.class != actual.class
|
|
113
|
+
return [path, expected, actual] unless expected.is_a?(Array) || expected.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
return diff_in_array(expected, actual, path) if expected.is_a?(Array)
|
|
116
|
+
|
|
117
|
+
diff_in_hash(expected, actual, path)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.diff_in_array(expected, actual, path)
|
|
121
|
+
[expected.length, actual.length].max.times do |i|
|
|
122
|
+
return ["#{path}[#{i}]", expected[i], actual[i]] if i >= expected.length || i >= actual.length
|
|
123
|
+
next if expected[i] == actual[i]
|
|
124
|
+
|
|
125
|
+
return first_difference(expected[i], actual[i], "#{path}[#{i}]")
|
|
126
|
+
end
|
|
127
|
+
[path, expected, actual]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.diff_in_hash(expected, actual, path)
|
|
131
|
+
(expected.keys | actual.keys).each do |key|
|
|
132
|
+
return ["#{path}[#{key.inspect}]", expected[key], actual[key]] if !expected.key?(key) || !actual.key?(key)
|
|
133
|
+
next if expected[key] == actual[key]
|
|
134
|
+
|
|
135
|
+
return first_difference(expected[key], actual[key], "#{path}[#{key.inspect}]")
|
|
136
|
+
end
|
|
137
|
+
[path, expected, actual]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Validates each expected row carries the required `:id`, `:class`,
|
|
141
|
+
# `:payload` keys. Without this, an expected row of `{ id: 0, class:
|
|
142
|
+
# :model }` would silently pass against any actual row whose payload
|
|
143
|
+
# was nil — because the `==` check reads `expected[:payload]` as nil.
|
|
144
|
+
def self.validate_expected_rows!(expected_rows)
|
|
145
|
+
expected_rows.each_with_index do |row, i|
|
|
146
|
+
unless row.is_a?(Hash)
|
|
147
|
+
raise ArgumentError,
|
|
148
|
+
"match_flight_structure: expected row #{i} must be a Hash, got #{row.class}: #{row.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
missing = REQUIRED_EXPECTED_KEYS.reject { |k| row.key?(k) }
|
|
152
|
+
next if missing.empty?
|
|
153
|
+
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"match_flight_structure: expected row #{i} is missing required keys: #{missing.inspect}. " \
|
|
156
|
+
"Each expected row must include :id, :class, and :payload."
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Validates a predicate hash for `include_flight_row`. Unknown keys
|
|
161
|
+
# (typos like `payloed:` or `clas:`) raise immediately so they don't
|
|
162
|
+
# silently match any row via `row[:payloed]` returning nil.
|
|
163
|
+
def self.validate_predicate!(predicate)
|
|
164
|
+
unless predicate.is_a?(Hash)
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"include_flight_row: predicate must be a Hash, got #{predicate.class}: #{predicate.inspect}"
|
|
167
|
+
end
|
|
168
|
+
raise ArgumentError, "include_flight_row: predicate cannot be empty" if predicate.empty?
|
|
169
|
+
|
|
170
|
+
unknown = predicate.keys - KNOWN_ROW_KEYS
|
|
171
|
+
return if unknown.empty?
|
|
172
|
+
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"include_flight_row: predicate has unknown keys: #{unknown.inspect}. " \
|
|
175
|
+
"Allowed keys: #{KNOWN_ROW_KEYS.inspect}."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.format_single(diff)
|
|
179
|
+
case diff[:kind]
|
|
180
|
+
when :missing
|
|
181
|
+
row = diff[:expected_row]
|
|
182
|
+
"Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
|
|
183
|
+
when :extra
|
|
184
|
+
row = diff[:actual_row]
|
|
185
|
+
"Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
|
|
186
|
+
else
|
|
187
|
+
format_field_diff(diff)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.format_field_diff(diff)
|
|
192
|
+
<<~MSG.strip
|
|
193
|
+
Expected Flight output to match structure.
|
|
194
|
+
|
|
195
|
+
Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
|
|
196
|
+
expected: #{diff[:expected].inspect}
|
|
197
|
+
got: #{diff[:got].inspect}
|
|
198
|
+
|
|
199
|
+
Row #{diff[:idx]} (#{diff[:row_class]}) full diff:
|
|
200
|
+
expected: #{diff[:expected_row][:payload].inspect}
|
|
201
|
+
got: #{diff[:got_row][:payload].inspect}
|
|
202
|
+
MSG
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Builds the multi-row failure message: header naming the diff count,
|
|
206
|
+
# AC3-specified wording for missing / extra / differing rows, and a
|
|
207
|
+
# `Row N (<class>): ✓` summary line for every matching row so the
|
|
208
|
+
# reader can see what passed.
|
|
209
|
+
def self.format_multi(diffs, actual_rows, expected_rows)
|
|
210
|
+
header = "Expected Flight output to match structure. #{diffs.length} rows differ:"
|
|
211
|
+
total = [actual_rows.length, expected_rows.length].max
|
|
212
|
+
diff_by_idx = diffs.to_h { |d| [d[:idx], d] }
|
|
213
|
+
|
|
214
|
+
body = (0...total).map do |i|
|
|
215
|
+
diff = diff_by_idx[i]
|
|
216
|
+
if diff
|
|
217
|
+
format_entry(diff)
|
|
218
|
+
else
|
|
219
|
+
row_class = (actual_rows[i] || expected_rows[i])[:class]
|
|
220
|
+
"Row #{i} (#{row_class}): ✓"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
([header, ""] + body).join("\n")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def self.format_entry(diff)
|
|
228
|
+
case diff[:kind]
|
|
229
|
+
when :missing
|
|
230
|
+
row = diff[:expected_row]
|
|
231
|
+
"Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
|
|
232
|
+
when :extra
|
|
233
|
+
row = diff[:actual_row]
|
|
234
|
+
"Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
|
|
235
|
+
else
|
|
236
|
+
<<~ENTRY.strip
|
|
237
|
+
Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
|
|
238
|
+
expected: #{diff[:expected].inspect}
|
|
239
|
+
got: #{diff[:got].inspect}
|
|
240
|
+
ENTRY
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Subset / case-equality match used by `include_flight_row`. Plain
|
|
245
|
+
# values use `==`; richer matchers (`hash_including`, `array_including`,
|
|
246
|
+
# `kind_of`, regexes) use `===` which delegates to their custom logic.
|
|
247
|
+
# Predicate keys are validated upfront by `validate_predicate!`, so
|
|
248
|
+
# this method can assume every key is one of the known row keys.
|
|
249
|
+
def self.row_matches?(row, predicate)
|
|
250
|
+
predicate.all? do |key, expected_value|
|
|
251
|
+
actual_value = row[key]
|
|
252
|
+
case expected_value
|
|
253
|
+
when Symbol, Numeric, NilClass, TrueClass, FalseClass
|
|
254
|
+
expected_value == actual_value
|
|
255
|
+
else
|
|
256
|
+
# rubocop:disable Style/CaseEquality -- intentional: lets RSpec mock argument matchers
|
|
257
|
+
# (hash_including, array_including, kind_of, etc.) drive predicate semantics via #===.
|
|
258
|
+
expected_value === actual_value
|
|
259
|
+
# rubocop:enable Style/CaseEquality
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
# rubocop:enable Metrics/ClassLength
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# `match_flight_fixture(name)` — Phase 1 byte-exact snapshot matcher.
|
|
269
|
+
#
|
|
270
|
+
# Reads `spec/fixtures/flight/<name>.txt` and compares the actual wire bytes
|
|
271
|
+
# to the file contents via `==`. Used when the wire format itself is the
|
|
272
|
+
# contract (escape rules, ordering invariants, payload framing).
|
|
273
|
+
#
|
|
274
|
+
# @example
|
|
275
|
+
# expect(serializer.serialize_value("$danger")).to match_flight_fixture("string_dollar_escape")
|
|
3
276
|
RSpec::Matchers.define :match_flight_fixture do |name|
|
|
4
277
|
match do |actual|
|
|
5
278
|
fixtures_dir = File.expand_path("../../fixtures/flight", __dir__)
|
|
@@ -23,3 +296,86 @@ RSpec::Matchers.define :match_flight_fixture do |name|
|
|
|
23
296
|
"match Flight wire fixture '#{name}'"
|
|
24
297
|
end
|
|
25
298
|
end
|
|
299
|
+
|
|
300
|
+
# `match_flight_structure(expected)` — Phase 2 structural matcher (Story 7.5).
|
|
301
|
+
#
|
|
302
|
+
# Parses the actual Flight wire output via `Ruact::Spec::FlightWireParser`,
|
|
303
|
+
# then compares the resulting array of row records against `expected`. Hash
|
|
304
|
+
# payloads are compared structurally (key insertion order is ignored), so
|
|
305
|
+
# cosmetic JSON re-ordering does not break specs that assert on semantics.
|
|
306
|
+
# Import rows are matched as a multiset; non-import rows are compared
|
|
307
|
+
# positionally because Flight semantics depend on their order.
|
|
308
|
+
#
|
|
309
|
+
# Failure messages name the row index and the differing field — bytes are
|
|
310
|
+
# only printed when no narrower diff is available.
|
|
311
|
+
#
|
|
312
|
+
# @example
|
|
313
|
+
# expect(wire).to match_flight_structure([
|
|
314
|
+
# { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]] },
|
|
315
|
+
# { id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
|
|
316
|
+
# ])
|
|
317
|
+
RSpec::Matchers.define :match_flight_structure do |expected|
|
|
318
|
+
match do |actual|
|
|
319
|
+
@parsed = Ruact::Spec::FlightWireParser.parse(actual)
|
|
320
|
+
@expected_rows = expected
|
|
321
|
+
@diffs = Ruact::Spec::FlightStructureDiff.compute(@parsed, expected)
|
|
322
|
+
@diffs.empty?
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
failure_message do |_actual|
|
|
326
|
+
if @diffs.length == 1
|
|
327
|
+
Ruact::Spec::FlightStructureDiff.format_single(@diffs.first)
|
|
328
|
+
else
|
|
329
|
+
Ruact::Spec::FlightStructureDiff.format_multi(@diffs, @parsed, @expected_rows)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
failure_message_when_negated do |_actual|
|
|
334
|
+
"Expected output NOT to match the given Flight wire structure, but it did."
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
description do
|
|
338
|
+
"match Flight wire structure (#{expected.length} row(s))"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# `include_flight_row(predicate)` — Phase 2 ordering-independent presence matcher (Story 7.5).
|
|
343
|
+
#
|
|
344
|
+
# Parses the actual Flight wire output, then asserts at least one parsed row
|
|
345
|
+
# satisfies the predicate hash. Subset semantics: only the keys present in
|
|
346
|
+
# `predicate` are compared. Predicate keys must be one of `:id`, `:class`,
|
|
347
|
+
# `:payload`, `:raw` — unknown keys raise upfront so typos don't silently
|
|
348
|
+
# match every row. Values may be plain Ruby values (compared with `==`) or
|
|
349
|
+
# RSpec argument matchers like `hash_including(...)` (compared with `===`).
|
|
350
|
+
# Negation (`not_to`) is supported.
|
|
351
|
+
#
|
|
352
|
+
# @example
|
|
353
|
+
# expect(wire).to include_flight_row(class: :model, payload: hash_including("postId" => 42))
|
|
354
|
+
RSpec::Matchers.define :include_flight_row do |predicate|
|
|
355
|
+
match do |actual|
|
|
356
|
+
Ruact::Spec::FlightStructureDiff.validate_predicate!(predicate)
|
|
357
|
+
@parsed = Ruact::Spec::FlightWireParser.parse(actual)
|
|
358
|
+
@predicate = predicate
|
|
359
|
+
@matched_idx = @parsed.find_index { |row| Ruact::Spec::FlightStructureDiff.row_matches?(row, predicate) }
|
|
360
|
+
!@matched_idx.nil?
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
failure_message do |_actual|
|
|
364
|
+
summary = @parsed.each_with_index.map do |row, i|
|
|
365
|
+
payload_snippet = row[:payload].inspect
|
|
366
|
+
payload_snippet = "#{payload_snippet[0, 80]}…" if payload_snippet.length > 80
|
|
367
|
+
" [#{i}] id=#{row[:id].inspect}, class=#{row[:class]}, payload=#{payload_snippet}"
|
|
368
|
+
end.join("\n")
|
|
369
|
+
summary = " (no rows parsed)" if @parsed.empty?
|
|
370
|
+
|
|
371
|
+
"Expected Flight output to include a row matching: #{predicate.inspect}.\nParsed rows:\n#{summary}"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
failure_message_when_negated do |_actual|
|
|
375
|
+
"Expected Flight output NOT to include a row matching: #{predicate.inspect}, but row #{@matched_idx} matched."
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
description do
|
|
379
|
+
"include a Flight row matching #{predicate.inspect}"
|
|
380
|
+
end
|
|
381
|
+
end
|