ruact 0.0.7 → 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 +24 -0
- 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 +9 -0
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- 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/tasks/ruact.rake +55 -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 +15 -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/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/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +14 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
# Public, host-app-facing test-support surface (FR108, Story 15.4). Loaded
|
|
8
|
+
# ONLY by an explicit `require "ruact/testing"` (or one of the files under
|
|
9
|
+
# `ruact/testing/`) — never by `require "ruact"`, so a production boot carries
|
|
10
|
+
# no RSpec/test dependency. The pure wire parser + structural diff engine live
|
|
11
|
+
# here as the SINGLE shipped implementation shared by both the public
|
|
12
|
+
# `have_ruact_component` matcher and the gem's internal `Ruact::Spec::*`
|
|
13
|
+
# matchers (promoted from `spec/support` in Story 15.4 — wrap, not fork).
|
|
14
|
+
module Testing
|
|
15
|
+
# Raised when {FlightWireParser.parse} encounters input it cannot decode.
|
|
16
|
+
# The message names the byte offset of the unparseable row so the caller
|
|
17
|
+
# can locate the problem in a printed wire string.
|
|
18
|
+
class FlightWireParseError < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Parses a Flight wire byte string into an ordered array of row records.
|
|
21
|
+
#
|
|
22
|
+
# Used by the structural Flight matchers to assert on parsed semantics
|
|
23
|
+
# rather than literal bytes. Pure function — no I/O, no global state, no
|
|
24
|
+
# `Thread.current`.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
|
|
28
|
+
# Ruact::Testing::FlightWireParser.parse(wire)
|
|
29
|
+
# # => [
|
|
30
|
+
# # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
|
|
31
|
+
# # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
|
|
32
|
+
# # ]
|
|
33
|
+
class FlightWireParser
|
|
34
|
+
# Parse a complete Flight wire byte string.
|
|
35
|
+
#
|
|
36
|
+
# @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
|
|
37
|
+
# @return [Array<Hash>] one hash per row, in wire order. See class docs
|
|
38
|
+
# for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
|
|
39
|
+
# @raise [Ruact::Testing::FlightWireParseError] when a row is malformed.
|
|
40
|
+
def self.parse(wire)
|
|
41
|
+
rows = []
|
|
42
|
+
scanner = StringScanner.new(wire)
|
|
43
|
+
|
|
44
|
+
until scanner.eos?
|
|
45
|
+
start_offset = scanner.pos
|
|
46
|
+
rows << parse_row(scanner, start_offset)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
rows
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.parse_row(scanner, start_offset)
|
|
53
|
+
# Hint rows have no ID: ":H<code><json>\n"
|
|
54
|
+
if scanner.peek(2) == ":H"
|
|
55
|
+
scanner.pos += 2
|
|
56
|
+
code = scanner.getch
|
|
57
|
+
raise_parse_error(start_offset, "missing hint code char") if code.nil?
|
|
58
|
+
|
|
59
|
+
json = read_to_newline(scanner, start_offset)
|
|
60
|
+
return {
|
|
61
|
+
id: nil,
|
|
62
|
+
class: :hint,
|
|
63
|
+
payload: [code, parse_json(json, start_offset)],
|
|
64
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
|
|
69
|
+
scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
|
|
70
|
+
id = hex.to_i(16)
|
|
71
|
+
|
|
72
|
+
case scanner.peek(1)
|
|
73
|
+
when "I" then parse_tagged(:import, scanner, id, start_offset)
|
|
74
|
+
when "T" then parse_text_row(scanner, id, start_offset)
|
|
75
|
+
when "E" then parse_tagged(:error, scanner, id, start_offset)
|
|
76
|
+
else parse_model_row(scanner, id, start_offset)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.parse_tagged(klass, scanner, id, start_offset)
|
|
81
|
+
scanner.getch # consume the tag byte (I or E)
|
|
82
|
+
json = read_to_newline(scanner, start_offset)
|
|
83
|
+
{
|
|
84
|
+
id: id,
|
|
85
|
+
class: klass,
|
|
86
|
+
payload: parse_json(json, start_offset),
|
|
87
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.parse_model_row(scanner, id, start_offset)
|
|
92
|
+
json = read_to_newline(scanner, start_offset)
|
|
93
|
+
{
|
|
94
|
+
id: id,
|
|
95
|
+
class: :model,
|
|
96
|
+
payload: parse_json(json, start_offset),
|
|
97
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.parse_text_row(scanner, id, start_offset)
|
|
102
|
+
scanner.getch # consume "T"
|
|
103
|
+
len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
|
|
104
|
+
scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
|
|
105
|
+
len = len_hex.to_i(16)
|
|
106
|
+
|
|
107
|
+
text = scanner.peek(len)
|
|
108
|
+
raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
|
|
109
|
+
|
|
110
|
+
scanner.pos += len
|
|
111
|
+
{
|
|
112
|
+
id: id,
|
|
113
|
+
class: :text,
|
|
114
|
+
payload: text,
|
|
115
|
+
raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.read_to_newline(scanner, start_offset)
|
|
120
|
+
line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
|
|
121
|
+
line.chomp
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.parse_json(str, offset)
|
|
125
|
+
JSON.parse(str)
|
|
126
|
+
rescue JSON::ParserError => e
|
|
127
|
+
raise_parse_error(offset, "invalid JSON: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.raise_parse_error(offset, reason)
|
|
131
|
+
raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
|
|
135
|
+
:read_to_newline, :parse_json, :raise_parse_error
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
|
|
5
|
+
require_relative "testing/flight_wire_parser"
|
|
6
|
+
require_relative "testing/flight_structure_diff"
|
|
7
|
+
require_relative "testing/flight_extractor"
|
|
8
|
+
require_relative "testing/component_query"
|
|
9
|
+
|
|
10
|
+
# Public, host-app-facing render-assertion helpers (FR108, Story 15.4).
|
|
11
|
+
#
|
|
12
|
+
# Load this EXPLICITLY from your `spec_helper.rb`/`rails_helper.rb`:
|
|
13
|
+
#
|
|
14
|
+
# require "ruact/testing"
|
|
15
|
+
#
|
|
16
|
+
# It is NOT auto-loaded by `require "ruact"` — a production boot never pulls in
|
|
17
|
+
# RSpec. Requiring this file registers the `have_ruact_component` RSpec matcher
|
|
18
|
+
# so request/controller specs can assert a page rendered a given component:
|
|
19
|
+
#
|
|
20
|
+
# expect(response).to have_ruact_component("PostList")
|
|
21
|
+
# expect(response).to have_ruact_component("PostList").with_props(including("posts"))
|
|
22
|
+
# expect(response).not_to have_ruact_component("Admin")
|
|
23
|
+
#
|
|
24
|
+
# `response` may be an ActionDispatch/Rack response (its `.body` is read) or a
|
|
25
|
+
# raw String, in either page shape: a raw `text/x-component` body or an HTML
|
|
26
|
+
# shell embedding `__FLIGHT_DATA`. A `Ruact::Server` function-call/query answer
|
|
27
|
+
# is plain JSON, not Flight — passing it raises a clear error pointing you at
|
|
28
|
+
# `JSON.parse(response.body)` (see the "Testing" docs page).
|
|
29
|
+
#
|
|
30
|
+
# This is a STABLE public API (a conventional test matcher) — it wraps, and
|
|
31
|
+
# does not fork, the internal Story-7.5 structural parser/diff promoted to
|
|
32
|
+
# `Ruact::Testing::FlightWireParser` / `Ruact::Testing::FlightStructureDiff`.
|
|
33
|
+
module Ruact
|
|
34
|
+
module Testing
|
|
35
|
+
# Compares a rendered instance's serialized props against the value passed
|
|
36
|
+
# to `.with_props`. A plain Hash requires exact equality (via `===`, i.e.
|
|
37
|
+
# `==`); an RSpec argument matcher (`hash_including`, `including`, `a_hash_…`)
|
|
38
|
+
# drives subset/fuzzy semantics through its own `#===`.
|
|
39
|
+
def self.props_match?(expected, actual_props)
|
|
40
|
+
# rubocop:disable Style/CaseEquality -- intentional: lets RSpec matchers (hash_including, …) drive semantics via #===.
|
|
41
|
+
expected === actual_props
|
|
42
|
+
# rubocop:enable Style/CaseEquality
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# rubocop:disable Metrics/BlockLength -- a single cohesive RSpec matcher definition (match + chain + messages).
|
|
48
|
+
RSpec::Matchers.define :have_ruact_component do |name|
|
|
49
|
+
match do |actual|
|
|
50
|
+
@query = Ruact::Testing::ComponentQuery.new(Ruact::Testing::FlightExtractor.extract(actual))
|
|
51
|
+
@instances_props = @query.props_for(name)
|
|
52
|
+
next false if @instances_props.empty?
|
|
53
|
+
next true unless defined?(@expected_props)
|
|
54
|
+
|
|
55
|
+
@instances_props.any? { |props| Ruact::Testing.props_match?(@expected_props, props) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
chain :with_props do |expected|
|
|
59
|
+
@expected_props = expected
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
failure_message do |_actual|
|
|
63
|
+
if @instances_props && @instances_props.empty?
|
|
64
|
+
rendered = @query.rendered_names
|
|
65
|
+
found = rendered.empty? ? "no ruact components" : "components: #{rendered.inspect}"
|
|
66
|
+
"expected the response to have rendered a ruact component named #{name.inspect}, " \
|
|
67
|
+
"but found #{found}."
|
|
68
|
+
else
|
|
69
|
+
"expected a rendered #{name.inspect} to have props matching #{@expected_props.inspect}, " \
|
|
70
|
+
"but none did. Rendered instance props (serialized wire form):\n" \
|
|
71
|
+
"#{@instances_props.map(&:inspect).join("\n")}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
failure_message_when_negated do |_actual|
|
|
76
|
+
if defined?(@expected_props)
|
|
77
|
+
"expected the response NOT to have a #{name.inspect} with props matching " \
|
|
78
|
+
"#{@expected_props.inspect}, but one did."
|
|
79
|
+
else
|
|
80
|
+
"expected the response NOT to have rendered a ruact component named #{name.inspect}, " \
|
|
81
|
+
"but it did."
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
description do
|
|
86
|
+
base = "have ruact component #{name.inspect}"
|
|
87
|
+
defined?(@expected_props) ? "#{base} with props #{@expected_props.inspect}" : base
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
# rubocop:enable Metrics/BlockLength
|
data/lib/ruact/version.rb
CHANGED
data/lib/tasks/ruact.rake
CHANGED
|
@@ -1,10 +1,63 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
namespace :ruact do
|
|
4
|
-
|
|
4
|
+
# Story 15.3 (FR107) — `--json` is passed after a `--` separator so neither
|
|
5
|
+
# Rails nor Rake tries to parse it as an option: `bin/rails ruact:doctor -- --json`.
|
|
6
|
+
# Everything after `--` lands in ARGV, which the task scans. A captured local
|
|
7
|
+
# (not a constant) so re-loading this rakefile — e.g. specs that load it into
|
|
8
|
+
# an isolated Rake::Application — does not warn about constant redefinition.
|
|
9
|
+
json_mode = -> { ARGV.include?("--json") }
|
|
10
|
+
|
|
11
|
+
desc "Check ruact installation and configuration (FR27). Append `-- --json` " \
|
|
12
|
+
"for a machine-readable report (EXPERIMENTAL shape — gate on schema_version)."
|
|
5
13
|
task doctor: :environment do
|
|
6
14
|
require "ruact/doctor"
|
|
7
|
-
|
|
15
|
+
|
|
16
|
+
if json_mode.call
|
|
17
|
+
require "json"
|
|
18
|
+
report = Ruact::Doctor.new.as_json
|
|
19
|
+
puts JSON.pretty_generate(report)
|
|
20
|
+
exit 1 unless report["status"] == "pass"
|
|
21
|
+
else
|
|
22
|
+
exit 1 unless Ruact::Doctor.run
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Story 15.3 (FR107) — read-only introspection of the accessor/route table
|
|
27
|
+
# (the SAME single source of truth codegen consumes) for headless agents & CI.
|
|
28
|
+
# Side-effect-free: it does NOT write the codegen bridge or the TS module.
|
|
29
|
+
# `-- --json` emits the machine-readable document; the bare task prints a
|
|
30
|
+
# compact human table. Exits 1 (message on stderr) on a naming collision,
|
|
31
|
+
# mirroring `ruact:server_functions:generate`.
|
|
32
|
+
desc "Print the ruact accessor/route table. Append `-- --json` for a " \
|
|
33
|
+
"machine-readable document (EXPERIMENTAL shape — gate on schema_version)."
|
|
34
|
+
task routes: :environment do
|
|
35
|
+
require "ruact/server_functions"
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
Rails.application.routes_reloader.execute_unless_loaded
|
|
39
|
+
|
|
40
|
+
if json_mode.call
|
|
41
|
+
require "json"
|
|
42
|
+
puts JSON.pretty_generate(
|
|
43
|
+
Ruact::ServerFunctions::Introspection.as_json(route_set: Rails.application.routes)
|
|
44
|
+
)
|
|
45
|
+
else
|
|
46
|
+
entries = Ruact::ServerFunctions.introspect(route_set: Rails.application.routes)
|
|
47
|
+
if entries.empty?
|
|
48
|
+
puts "[ruact] no server-function accessors (no Ruact::Server actions or mounted queries)"
|
|
49
|
+
else
|
|
50
|
+
puts "[ruact] server-function accessors:"
|
|
51
|
+
entries.each do |entry|
|
|
52
|
+
puts format(" %-6<verb>s %-28<accessor>s %<path>s",
|
|
53
|
+
verb: entry["http_method"], accessor: entry["js_identifier"], path: entry["path"])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
rescue Ruact::ConfigurationError, Errno::ENOENT, JSON::ParserError => e
|
|
58
|
+
warn "[ruact] error: #{e.message}"
|
|
59
|
+
exit 1
|
|
60
|
+
end
|
|
8
61
|
end
|
|
9
62
|
|
|
10
63
|
namespace :server_functions do
|
data/spec/ruact/doctor_spec.rb
CHANGED
|
@@ -511,6 +511,141 @@ RSpec.describe Ruact::Doctor do
|
|
|
511
511
|
end
|
|
512
512
|
end
|
|
513
513
|
|
|
514
|
+
# --- JSON introspection (Story 15.3, FR107, AC1 + AC3 + AC4) ---
|
|
515
|
+
|
|
516
|
+
describe "#as_json (machine-readable report)", :story_15_3 do
|
|
517
|
+
subject(:doctor) { described_class.new }
|
|
518
|
+
|
|
519
|
+
context "when all checks pass" do
|
|
520
|
+
before do
|
|
521
|
+
make_manifest
|
|
522
|
+
make_controller(with_include: true)
|
|
523
|
+
make_layout(with_sentinel: true)
|
|
524
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
it "returns a document that round-trips through JSON.parse", :aggregate_failures do
|
|
528
|
+
report = doctor.as_json
|
|
529
|
+
reparsed = JSON.parse(JSON.generate(report))
|
|
530
|
+
expect(reparsed).to eq(report)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
it "gates the document with the EXPERIMENTAL schema_version (0)" do
|
|
534
|
+
expect(doctor.as_json["schema_version"]).to eq(0)
|
|
535
|
+
expect(doctor.as_json["schema_version"]).to eq(Ruact::Doctor::SCHEMA_VERSION)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
it "carries every check with name/status/message/remediation keys", :aggregate_failures do
|
|
539
|
+
checks = doctor.as_json["checks"]
|
|
540
|
+
expect(checks.map { |c| c["name"] }).to eq(described_class::CHECKS.map(&:to_s))
|
|
541
|
+
checks.each do |check|
|
|
542
|
+
expect(check.keys).to contain_exactly("name", "status", "message", "remediation")
|
|
543
|
+
expect(check["status"]).to be_a(String)
|
|
544
|
+
expect(check["message"]).to be_a(String)
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
it "reports top-level status 'pass' and exits-zero semantics" do
|
|
549
|
+
expect(doctor.as_json["status"]).to eq("pass")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it "emits NO prose to stdout (JSON mode is the document only)" do
|
|
553
|
+
expect { doctor.as_json }.not_to output.to_stdout
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
context "when a check fails" do
|
|
558
|
+
before do
|
|
559
|
+
make_controller(with_include: true)
|
|
560
|
+
make_layout(with_sentinel: true)
|
|
561
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
562
|
+
# manifest is missing → check_manifest fails
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
it "reports top-level status 'fail' (non-zero exit semantics)" do
|
|
566
|
+
expect(doctor.as_json["status"]).to eq("fail")
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
it "carries the separate machine-readable remediation for the failing check" do
|
|
570
|
+
manifest = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }
|
|
571
|
+
expect(manifest["status"]).to eq("fail")
|
|
572
|
+
expect(manifest["remediation"]).to eq("Run vite build (or bin/dev) to generate the client manifest.")
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
context "when a check warns (Rack::Deflater mounted)" do
|
|
577
|
+
before do
|
|
578
|
+
make_manifest
|
|
579
|
+
make_controller(with_include: true)
|
|
580
|
+
make_layout(with_sentinel: true)
|
|
581
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
582
|
+
app = Struct.new(:middleware).new([Struct.new(:name).new("Rack::Deflater")])
|
|
583
|
+
allow(Rails).to receive(:application).and_return(app)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
it "keeps status 'pass' — a :warn does not fail — but exposes the warn check" do
|
|
587
|
+
report = doctor.as_json
|
|
588
|
+
expect(report["status"]).to eq("pass")
|
|
589
|
+
flight = report["checks"].find { |c| c["name"] == "flight_middleware" }
|
|
590
|
+
expect(flight["status"]).to eq("warn")
|
|
591
|
+
expect(flight["remediation"]).to include("Exclude text/x-component from compression")
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
it "keeps the message byte-identical to the human tuple (remediation is separate)" do
|
|
596
|
+
_status, human_message = doctor.send(:check_manifest)
|
|
597
|
+
json_message = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }["message"]
|
|
598
|
+
expect(json_message).to eq(human_message)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
describe "#results / #passed? (shared compute, Story 15.3)", :story_15_3 do
|
|
603
|
+
subject(:doctor) { described_class.new }
|
|
604
|
+
|
|
605
|
+
before do
|
|
606
|
+
make_manifest
|
|
607
|
+
make_controller(with_include: true)
|
|
608
|
+
make_layout(with_sentinel: true)
|
|
609
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
it "returns one tuple per check, index-aligned with CHECKS" do
|
|
613
|
+
expect(doctor.results.length).to eq(described_class::CHECKS.length)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
it "passed? is true when all checks pass/warn" do
|
|
617
|
+
expect(doctor.passed?).to be true
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
it "runs each check exactly once (as_json does not double-run check_vite's socket)" do
|
|
621
|
+
# check_vite opens a socket via TCPSocket.new; a single as_json must open it
|
|
622
|
+
# exactly once — proving #results is computed once, not per consumer.
|
|
623
|
+
doctor.as_json
|
|
624
|
+
expect(TCPSocket).to have_received(:new).once
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
describe "#run stays byte-identical after the #results refactor (Story 15.3, AC4a)", :story_15_3 do
|
|
629
|
+
subject(:doctor) { described_class.new }
|
|
630
|
+
|
|
631
|
+
before do
|
|
632
|
+
make_manifest
|
|
633
|
+
make_controller(with_include: true)
|
|
634
|
+
make_layout(with_sentinel: true)
|
|
635
|
+
allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
it "prints exactly the header + one format_result line per check (no JSON/prose leak), returns true" do
|
|
639
|
+
# Reconstruct the expected output from the SAME format_result the human
|
|
640
|
+
# path uses — proves #run still emits header + one glyph line per check
|
|
641
|
+
# (the optional 3rd remediation element is ignored) and nothing else.
|
|
642
|
+
lines = doctor.results.map { |status, message| doctor.send(:format_result, status, message) }.join("\n")
|
|
643
|
+
expected = "[ruact] Health check\n#{lines}\n"
|
|
644
|
+
|
|
645
|
+
expect { expect(doctor.run).to be true }.to output(expected).to_stdout
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
514
649
|
describe "Rake task definition (Story 5.12)", :story_5_12 do
|
|
515
650
|
# Loads gem/lib/tasks/ruact.rake into a fresh Rake::Application so the
|
|
516
651
|
# task table is isolated from any other spec that may have loaded tasks.
|
|
@@ -549,5 +684,11 @@ RSpec.describe Ruact::Doctor do
|
|
|
549
684
|
# actions is a list of Procs; just assert at least one is attached
|
|
550
685
|
expect(task.actions).not_to be_empty
|
|
551
686
|
end
|
|
687
|
+
|
|
688
|
+
it "defines Rake::Task['ruact:routes'] with an action (Story 15.3)", :story_15_3 do
|
|
689
|
+
task = rake_app.lookup("ruact:routes")
|
|
690
|
+
expect(task).not_to be_nil
|
|
691
|
+
expect(task.actions).not_to be_empty
|
|
692
|
+
end
|
|
552
693
|
end
|
|
553
694
|
end
|
|
@@ -116,6 +116,151 @@ module Ruact
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# Story 15.2 (FR106) — children inside a PascalCase component tag (a matching
|
|
120
|
+
# closing tag) fail LOUDLY at preprocess time instead of degrading silently.
|
|
121
|
+
describe "loud children error (FR106)", :story_15_2 do
|
|
122
|
+
def run(source, identifier: nil)
|
|
123
|
+
described_class.transform(source, identifier: identifier)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "raises on `<Card>Hello</Card>` naming component + fix (AC#1)" do
|
|
127
|
+
expect { run("<Card>Hello</Card>") }
|
|
128
|
+
.to raise_error(ChildrenNotSupportedError) do |e|
|
|
129
|
+
expect(e.message).to include("Card")
|
|
130
|
+
expect(e.message).to include("children are not supported")
|
|
131
|
+
expect(e.message).to include("pass content as a prop")
|
|
132
|
+
expect(e.message).to include("<Card content={...} />")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "is a Ruact::PreprocessorError (AC#1 — subclass IS-A base)" do
|
|
137
|
+
expect { run("<Card>Hello</Card>") }.to raise_error(Ruact::PreprocessorError)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "raises on an empty pair `<Card></Card>` (AC#1)" do
|
|
141
|
+
expect { run("<Card></Card>") }.to raise_error(ChildrenNotSupportedError, /Card/)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "raises on multi-line children (AC#1)" do
|
|
145
|
+
source = "<Card>\n <p>hi</p>\n</Card>"
|
|
146
|
+
expect { run(source) }.to raise_error(ChildrenNotSupportedError, /children are not supported/)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "raises on a paired tag that also holds a nested component" do
|
|
150
|
+
expect { run("<Card><Button /></Card>") }
|
|
151
|
+
.to raise_error(ChildrenNotSupportedError, /<Card>/)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "names the supplied identifier and the correct line for a non-first-line pair (AC#4)" do
|
|
155
|
+
source = "line1\nline2\n<Card>Hello</Card>"
|
|
156
|
+
expect { run(source, identifier: "app/views/posts/show.html.erb") }
|
|
157
|
+
.to raise_error(ChildrenNotSupportedError) do |e|
|
|
158
|
+
expect(e.message).to include("app/views/posts/show.html.erb:3")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "reports the opening tag's line, honoring attributes on the opening tag" do
|
|
163
|
+
expect { run("<Card variant={:wide}>x</Card>", identifier: "t.erb") }
|
|
164
|
+
.to raise_error(ChildrenNotSupportedError, /t\.erb:1/)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Regression (Codex Round 1, Patch 1): a MULTI-LINE Suspense opening tag
|
|
168
|
+
# must not shift the reported line — the `<Card>` below is physically on
|
|
169
|
+
# line 5 and must report :5 (Suspense is masked newline-for-newline).
|
|
170
|
+
it "reports the exact source line even after a multi-line Suspense opening" do
|
|
171
|
+
source = <<~ERB
|
|
172
|
+
<Suspense
|
|
173
|
+
fallback="loading"
|
|
174
|
+
delay="2.5">
|
|
175
|
+
</Suspense>
|
|
176
|
+
<Card>Hello</Card>
|
|
177
|
+
ERB
|
|
178
|
+
expect { run(source, identifier: "t.erb") }
|
|
179
|
+
.to raise_error(ChildrenNotSupportedError, /t\.erb:5/)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Regression (Codex Round 2, Patch 1): a `</Dialog>` living inside an ERB
|
|
183
|
+
# island (Ruby string/comment) must NOT be mistaken for a real component
|
|
184
|
+
# closing tag — a valid bare `<Dialog open={true}>` stays valid.
|
|
185
|
+
it "does not false-pair a bare opening with a `</Tag>` inside an ERB island" do
|
|
186
|
+
source = %(<Dialog open={true}>\n<% x = "</Dialog>" %>)
|
|
187
|
+
expect { run(source) }.not_to raise_error
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "still fires when the closing tag is real ERB body, not inside `<% %>`" do
|
|
191
|
+
expect { run("<Card><%= @body %></Card>") }
|
|
192
|
+
.to raise_error(ChildrenNotSupportedError, /Card/)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Regression (Codex Round 2, Patch 2): many bare non-self-closing openings
|
|
196
|
+
# with no close must stay linear (single-pass stack scan) and silent (D3).
|
|
197
|
+
it "stays silent and does not blow up on many bare unclosed openings" do
|
|
198
|
+
source = "<Dialog open={true}>\n" * 5000
|
|
199
|
+
expect { run(source) }.not_to raise_error
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Regression (Codex Round 3, Patch 1): stray/unmatched PascalCase closing
|
|
203
|
+
# tags (no preceding matching open) are literal text — never an error — and
|
|
204
|
+
# must stay linear (per-name O(1) lookup, no `rindex`). Correctness pin;
|
|
205
|
+
# perf verified live, not timed here (avoids a flaky benchmark spec).
|
|
206
|
+
it "does not raise on stray closing tags with no matching opening" do
|
|
207
|
+
source = "</Card>\n" * 5000
|
|
208
|
+
expect { run(source) }.not_to raise_error
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "still raises when a real opening precedes the stray closes" do
|
|
212
|
+
source = "<Card>x</Card>\n#{'</Card>' * 100}"
|
|
213
|
+
expect { run(source) }.to raise_error(ChildrenNotSupportedError, /Card/)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# AC#2 regression — the loud error must NOT fire on any valid pattern.
|
|
217
|
+
describe "does NOT fire on valid patterns (AC#2 byte-identical)" do
|
|
218
|
+
it "self-closing tag, no props" do
|
|
219
|
+
expect { run("<Button />") }.not_to raise_error
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "self-closing tag with props" do
|
|
223
|
+
expect { run("<LikeButton postId={@post.id} />") }.not_to raise_error
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "bare non-self-closing opening tag with NO closing tag (`<Dialog open={true}>`)" do
|
|
227
|
+
expect { run("<Dialog open={true}>") }.not_to raise_error
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it "nested-brace prop value" do
|
|
231
|
+
expect { run("<Select options={Category.all.map { |c| c.id }} />") }.not_to raise_error
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "multiple self-closing components" do
|
|
235
|
+
expect { run('<Button /> and <Badge label={"hello"} />') }.not_to raise_error
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "mixed HTML around a self-closing component" do
|
|
239
|
+
source = %(<div class="container">\n <h1>Hi</h1>\n <LikeButton postId={1} />\n</div>)
|
|
240
|
+
expect { run(source) }.not_to raise_error
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "emits byte-identical output for `<Dialog open={true}>`" do
|
|
244
|
+
expect(run("<Dialog open={true}>"))
|
|
245
|
+
.to eq(%(<%= __ruact_component__("Dialog", { "open" => true }) %>))
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# AC#3 — Suspense children are the ONE legitimate paired PascalCase tag and
|
|
250
|
+
# must never trip the error (normalized to <ruact-suspense> in Step 1).
|
|
251
|
+
describe "Suspense children never trip the error (AC#3)" do
|
|
252
|
+
it "does not raise for `<Suspense><Spinner /></Suspense>`" do
|
|
253
|
+
expect { run(%(<Suspense fallback="loading"><Spinner /></Suspense>)) }.not_to raise_error
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "normalizes Suspense to <ruact-suspense> exactly as today" do
|
|
257
|
+
result = run(%(<Suspense fallback="loading"><Spinner /></Suspense>))
|
|
258
|
+
expect(result).to include(%(data-ruact-fallback="loading"))
|
|
259
|
+
expect(result).to include("</ruact-suspense>")
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
119
264
|
# Story 13.5 (FR100) — preprocess-time component-contract validation, wired
|
|
120
265
|
# through an injectable registry seam (a stub responding to +contract_for+).
|
|
121
266
|
describe "component contract validation", :story_13_5 do
|