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
|
@@ -359,6 +359,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
359
359
|
gen.create_server_functions_directory
|
|
360
360
|
gen.append_gitignore_entries
|
|
361
361
|
gen.create_vite_config
|
|
362
|
+
gen.create_agents_md
|
|
362
363
|
end.not_to raise_error
|
|
363
364
|
end
|
|
364
365
|
|
|
@@ -371,6 +372,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
371
372
|
expect(File.read(File.join(app_root, ".gitignore")))
|
|
372
373
|
.to include("app/javascript/.ruact/server-functions.ts")
|
|
373
374
|
expect(File).to exist(File.join(app_root, "vite.config.js"))
|
|
375
|
+
expect(File).to exist(File.join(app_root, "AGENTS.md"))
|
|
374
376
|
end
|
|
375
377
|
end
|
|
376
378
|
end
|
|
@@ -889,4 +891,287 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
889
891
|
end
|
|
890
892
|
end
|
|
891
893
|
end
|
|
894
|
+
|
|
895
|
+
# Story 15.1 (FR105) — `ruact:install` emits an AGENTS.md teaching coding
|
|
896
|
+
# agents the ruact conventions, traps, and verification commands. The ruact
|
|
897
|
+
# content is delimited by explicit markers so re-runs are idempotent, a
|
|
898
|
+
# user-authored AGENTS.md is appended to (never clobbered), and --force
|
|
899
|
+
# refreshes ONLY the marked section. Content assertions grep stable tokens
|
|
900
|
+
# (commands, file paths, API names) — never full-file snapshots, since later
|
|
901
|
+
# stories (15.2/15.3/15.4) deliberately evolve the prose.
|
|
902
|
+
describe "install generator — AGENTS.md emission (Story 15.1 — FR105)", :story_15_1 do
|
|
903
|
+
require "stringio"
|
|
904
|
+
require "generators/ruact/install/install_generator"
|
|
905
|
+
|
|
906
|
+
let(:app_root) { Dir.mktmpdir("ruact_install_1501") }
|
|
907
|
+
let(:agents_md_path) { File.join(app_root, "AGENTS.md") }
|
|
908
|
+
let(:begin_marker) { "<!-- ruact:begin -->" }
|
|
909
|
+
let(:end_marker) { "<!-- ruact:end -->" }
|
|
910
|
+
let(:template_path) do
|
|
911
|
+
File.expand_path("../../lib/generators/ruact/install/templates/AGENTS.md.tt", __dir__)
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
after { FileUtils.rm_rf(app_root) }
|
|
915
|
+
|
|
916
|
+
def build_generator(root, opts = {})
|
|
917
|
+
Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def silently
|
|
921
|
+
original = $stdout
|
|
922
|
+
$stdout = StringIO.new
|
|
923
|
+
yield
|
|
924
|
+
ensure
|
|
925
|
+
$stdout = original
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
# Captures and returns the generator's say/say_status stdout for text asserts.
|
|
929
|
+
def capture_stdout
|
|
930
|
+
original = $stdout
|
|
931
|
+
$stdout = StringIO.new
|
|
932
|
+
yield
|
|
933
|
+
$stdout.string
|
|
934
|
+
ensure
|
|
935
|
+
$stdout = original
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
describe "fresh emission (AC#1)" do
|
|
939
|
+
it "creates AGENTS.md delimited by the ruact markers", :aggregate_failures do
|
|
940
|
+
silently { build_generator(app_root).create_agents_md }
|
|
941
|
+
|
|
942
|
+
expect(File).to exist(agents_md_path)
|
|
943
|
+
content = File.read(agents_md_path)
|
|
944
|
+
expect(content).to start_with(begin_marker)
|
|
945
|
+
expect(content.rstrip).to end_with(end_marker)
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
it "covers the model, codegen, queries and verification areas", :aggregate_failures do
|
|
949
|
+
silently { build_generator(app_root).create_agents_md }
|
|
950
|
+
content = File.read(agents_md_path)
|
|
951
|
+
|
|
952
|
+
# The verb rule (mutations = non-GET routed actions on Ruact::Server)
|
|
953
|
+
expect(content).to include("Ruact::Server")
|
|
954
|
+
expect(content).to include("non-GET")
|
|
955
|
+
# Route-driven codegen: ground-truth file + regenerate command
|
|
956
|
+
expect(content).to include("app/javascript/.ruact/server-functions.ts")
|
|
957
|
+
expect(content).to include("bin/rails ruact:server_functions:generate")
|
|
958
|
+
# Queries (reads)
|
|
959
|
+
expect(content).to include("Ruact::Query")
|
|
960
|
+
expect(content).to include("app/queries/")
|
|
961
|
+
expect(content).to include("ruact_queries")
|
|
962
|
+
expect(content).to include("useQuery")
|
|
963
|
+
# Verification commands (the only two that exist today)
|
|
964
|
+
expect(content).to include("bin/rails ruact:doctor")
|
|
965
|
+
# Docs pointers
|
|
966
|
+
expect(content).to include("https://ruact.dev")
|
|
967
|
+
expect(content).to include("llms.txt")
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
it "covers the five traps and the safety areas", :aggregate_failures do
|
|
971
|
+
silently { build_generator(app_root).create_agents_md }
|
|
972
|
+
content = File.read(agents_md_path)
|
|
973
|
+
|
|
974
|
+
# Trap 1 — children unsupported / self-closing only
|
|
975
|
+
expect(content).to include("self-closing")
|
|
976
|
+
expect(content).to include("children")
|
|
977
|
+
# Trap 2 — Ruby (not JS) inside {} props
|
|
978
|
+
expect(content).to include("Ruby, not JavaScript")
|
|
979
|
+
# Trap 3 — Accept-header dual shape (exact application/json)
|
|
980
|
+
expect(content).to include("Accept")
|
|
981
|
+
expect(content).to include("application/json")
|
|
982
|
+
# Trap 4 — ruact_errors fall-through
|
|
983
|
+
expect(content).to include("ruact_errors")
|
|
984
|
+
expect(content).to include("fall-through")
|
|
985
|
+
# Trap 5 — name derivation from routes
|
|
986
|
+
expect(content).to include("createPost")
|
|
987
|
+
expect(content).to include("ruact_function_name")
|
|
988
|
+
# Serialization allowlist
|
|
989
|
+
expect(content).to include("ruact_props")
|
|
990
|
+
expect(content).to include("strict_serialization")
|
|
991
|
+
# SGID helpers (purpose + expiry required grain)
|
|
992
|
+
expect(content).to include("Ruact.signed_global_id")
|
|
993
|
+
expect(content).to include("Ruact.locate_signed")
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
it "makes no claim before its artifact (AC#4 — content honesty)", :aggregate_failures do
|
|
997
|
+
silently { build_generator(app_root).create_agents_md }
|
|
998
|
+
content = File.read(agents_md_path)
|
|
999
|
+
|
|
1000
|
+
# (The loud children `PreprocessorError` is Story 15.2's shipped
|
|
1001
|
+
# artifact, and the `--json` introspection commands are Story 15.3's —
|
|
1002
|
+
# both may now be claimed; see the `:story_15_2` / `:story_15_3` guards
|
|
1003
|
+
# below that PIN each claim's presence. Story 15.4's public render
|
|
1004
|
+
# helpers are now shipped too — see the `:story_15_4` guard below.)
|
|
1005
|
+
#
|
|
1006
|
+
# tsc does not run in a fresh install (no typescript devDep / tsconfig) —
|
|
1007
|
+
# it may only appear conditionally phrased (D1).
|
|
1008
|
+
expect(content).to match(/if your app has typescript tooling/i) if content.include?("tsc")
|
|
1009
|
+
# Volatile strings must not be quoted: no gem version pins.
|
|
1010
|
+
expect(content).not_to match(/\b0\.0\.\d+\b/)
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# Story 15.2 (D2) — now that the loud children error is a shipped artifact,
|
|
1014
|
+
# trap #1 truthfully claims it fails LOUDLY (was "fails silently" in 15.1).
|
|
1015
|
+
it "trap #1 claims the loud PreprocessorError (Story 15.2 D2)", :aggregate_failures, :story_15_2 do
|
|
1016
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1017
|
+
content = File.read(agents_md_path)
|
|
1018
|
+
|
|
1019
|
+
expect(content).to include("PreprocessorError")
|
|
1020
|
+
expect(content).to match(/fail LOUDLY/i)
|
|
1021
|
+
expect(content).not_to match(/fail silently/i)
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# Story 15.3 (D2) — now that the `--json` introspection surface is a shipped
|
|
1025
|
+
# artifact, the "Verify your work" section references it (relaxes the 15.1
|
|
1026
|
+
# honesty guard above). This PINS the claim AND its EXPERIMENTAL framing so
|
|
1027
|
+
# the template never presents the JSON shape as a stable contract.
|
|
1028
|
+
it "references --json introspection as EXPERIMENTAL (15.3 D2)", :aggregate_failures, :story_15_3 do
|
|
1029
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1030
|
+
content = File.read(agents_md_path)
|
|
1031
|
+
|
|
1032
|
+
expect(content).to include("ruact:routes -- --json")
|
|
1033
|
+
expect(content).to include("ruact:doctor")
|
|
1034
|
+
expect(content).to include("Append `-- --json`")
|
|
1035
|
+
expect(content).to match(/EXPERIMENTAL/)
|
|
1036
|
+
expect(content).to include("schema_version")
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Story 15.4 (D6) — now that the public render-assertion helpers are a
|
|
1040
|
+
# shipped artifact (FR108), the "Verify your work" section references them.
|
|
1041
|
+
# This PINS the claim (name + explicit require + the JSON-vs-Flight rule)
|
|
1042
|
+
# so it stays consistent with `website/public/llms.txt`.
|
|
1043
|
+
it "references the ruact/testing render helpers (15.4 D6)", :aggregate_failures, :story_15_4 do
|
|
1044
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1045
|
+
content = File.read(agents_md_path)
|
|
1046
|
+
|
|
1047
|
+
expect(content).to include(%(require "ruact/testing"))
|
|
1048
|
+
expect(content).to include("have_ruact_component")
|
|
1049
|
+
expect(content).to include("JSON.parse(response.body)")
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
describe "idempotency (AC#2 — double-run zero diff)" do
|
|
1054
|
+
it "leaves the file byte-identical on a second run and reports a skip", :aggregate_failures do
|
|
1055
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1056
|
+
first = File.read(agents_md_path)
|
|
1057
|
+
|
|
1058
|
+
output = capture_stdout { build_generator(app_root).create_agents_md }
|
|
1059
|
+
|
|
1060
|
+
expect(File.read(agents_md_path)).to eq(first)
|
|
1061
|
+
expect(output).to include("skip")
|
|
1062
|
+
end
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
describe "append mode (AC#2 — user-authored AGENTS.md)" do
|
|
1066
|
+
let(:user_content) { "# My app\n\nUse pnpm here. Never touch config/secrets.yml.\n" }
|
|
1067
|
+
|
|
1068
|
+
it "appends the marked section preserving every pre-existing user byte", :aggregate_failures do
|
|
1069
|
+
File.write(agents_md_path, user_content)
|
|
1070
|
+
|
|
1071
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1072
|
+
content = File.read(agents_md_path)
|
|
1073
|
+
|
|
1074
|
+
expect(content).to start_with(user_content)
|
|
1075
|
+
expect(content.scan(begin_marker).count).to eq(1)
|
|
1076
|
+
expect(content.scan(end_marker).count).to eq(1)
|
|
1077
|
+
expect(content).to include("\n\n#{begin_marker}")
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
it "blank-line separates the section when the user file lacks a trailing newline" do
|
|
1081
|
+
File.write(agents_md_path, "# My app — no trailing newline")
|
|
1082
|
+
|
|
1083
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1084
|
+
|
|
1085
|
+
expect(File.read(agents_md_path))
|
|
1086
|
+
.to include("# My app — no trailing newline\n\n#{begin_marker}")
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
it "appending then re-running is idempotent (skip, zero diff)" do
|
|
1090
|
+
File.write(agents_md_path, user_content)
|
|
1091
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1092
|
+
appended = File.read(agents_md_path)
|
|
1093
|
+
|
|
1094
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1095
|
+
|
|
1096
|
+
expect(File.read(agents_md_path)).to eq(appended)
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
describe "--force refresh (AC#2 — marked-block-only)" do
|
|
1101
|
+
it "replaces ONLY the between-marker content, never user bytes outside", :aggregate_failures do
|
|
1102
|
+
silently { build_generator(app_root).create_agents_md }
|
|
1103
|
+
section = File.read(agents_md_path)
|
|
1104
|
+
stale = section.sub("## Five traps", "## STALE CONTENT FROM AN OLDER GEM")
|
|
1105
|
+
File.write(agents_md_path, "# mine, above\n\n#{stale}\n# mine, below\n")
|
|
1106
|
+
|
|
1107
|
+
silently { build_generator(app_root, { force: true }).create_agents_md }
|
|
1108
|
+
content = File.read(agents_md_path)
|
|
1109
|
+
|
|
1110
|
+
expect(content).to start_with("# mine, above\n")
|
|
1111
|
+
expect(content).to end_with("# mine, below\n")
|
|
1112
|
+
expect(content).to include("## Five traps")
|
|
1113
|
+
expect(content).not_to include("STALE CONTENT FROM AN OLDER GEM")
|
|
1114
|
+
expect(content.scan(begin_marker).count).to eq(1)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
it "creates the file under --force when none exists" do
|
|
1118
|
+
silently { build_generator(app_root, { force: true }).create_agents_md }
|
|
1119
|
+
expect(File.read(agents_md_path)).to include(begin_marker)
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
# Codex R1 — an incomplete marker state is ambiguous in BOTH directions
|
|
1124
|
+
# (appending would duplicate content next to a stray marker; replacing
|
|
1125
|
+
# would guess the range), so the only byte-safe move is warn + no-op.
|
|
1126
|
+
describe "broken marker pair (byte-safety)" do
|
|
1127
|
+
[
|
|
1128
|
+
["a begin marker without its end marker",
|
|
1129
|
+
"# mine\n\n<!-- ruact:begin -->\ntruncated ruact section, no end marker\n"],
|
|
1130
|
+
["an end marker without its begin marker",
|
|
1131
|
+
"# mine\n\nstray tail of a ruact section\n<!-- ruact:end -->\n"],
|
|
1132
|
+
["an end marker preceding the begin marker",
|
|
1133
|
+
"# mine\n\n<!-- ruact:end -->\nreversed\n<!-- ruact:begin -->\n"],
|
|
1134
|
+
# Codex R2 — a VALID pair plus stray extra markers is still ambiguous:
|
|
1135
|
+
# skip/refresh would leave (or eat past) the unmatched marker.
|
|
1136
|
+
["a stray end marker before a valid pair",
|
|
1137
|
+
"<!-- ruact:end -->\nstray\n\n<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n"],
|
|
1138
|
+
["a stray begin marker after a valid pair",
|
|
1139
|
+
"<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n\nstray\n<!-- ruact:begin -->\n"],
|
|
1140
|
+
["two complete marker pairs",
|
|
1141
|
+
"<!-- ruact:begin -->\none\n<!-- ruact:end -->\n\n<!-- ruact:begin -->\ntwo\n<!-- ruact:end -->\n"]
|
|
1142
|
+
].each do |(label, broken)|
|
|
1143
|
+
it "warns and leaves the file untouched with #{label} (no --force)", :aggregate_failures do
|
|
1144
|
+
File.write(agents_md_path, broken)
|
|
1145
|
+
|
|
1146
|
+
output = capture_stdout { build_generator(app_root).create_agents_md }
|
|
1147
|
+
|
|
1148
|
+
expect(File.read(agents_md_path)).to eq(broken)
|
|
1149
|
+
expect(output).to include("warn")
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
it "warns and leaves the file untouched with #{label} (--force)", :aggregate_failures do
|
|
1153
|
+
File.write(agents_md_path, broken)
|
|
1154
|
+
|
|
1155
|
+
output = capture_stdout { build_generator(app_root, { force: true }).create_agents_md }
|
|
1156
|
+
|
|
1157
|
+
expect(File.read(agents_md_path)).to eq(broken)
|
|
1158
|
+
expect(output).to include("warn")
|
|
1159
|
+
end
|
|
1160
|
+
end
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
describe "action ordering + template budget" do
|
|
1164
|
+
it "defines create_agents_md BEFORE install_javascript_dependencies" do
|
|
1165
|
+
klass = Ruact::Generators::InstallGenerator
|
|
1166
|
+
line = ->(name) { klass.instance_method(name).source_location.last }
|
|
1167
|
+
expect(line.call(:create_agents_md)).to be < line.call(:install_javascript_dependencies)
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# AC#3 tripwire — the ~150-line budget is the epic's scope probe; the
|
|
1171
|
+
# tripwire's tolerance is 160 (the tripwire's, not the product contract's).
|
|
1172
|
+
it "keeps the template body at or under 160 lines" do
|
|
1173
|
+
expect(File.read(template_path).lines.count).to be <= 160
|
|
1174
|
+
end
|
|
1175
|
+
end
|
|
1176
|
+
end
|
|
892
1177
|
end
|
|
@@ -107,6 +107,21 @@ module Ruact
|
|
|
107
107
|
expect { described_class.resolve }.to raise_error(ManifestError, %r{Vite dev server.*bin/dev}m)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Story 15.0 (F8) — an error message is a prompt: agents (and humans)
|
|
111
|
+
# regex-match error text, so the ENGLISH wording and its stable tokens
|
|
112
|
+
# (`Vite dev server`, `react-client-manifest.json`, `bin/dev`) are pinned.
|
|
113
|
+
it "raises the ENGLISH message naming the dev-server URL, the manifest path and the bin/dev fix " \
|
|
114
|
+
"(Story 15.0)", :story_15_0 do
|
|
115
|
+
allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
|
|
116
|
+
allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
|
|
117
|
+
|
|
118
|
+
expect { described_class.resolve }.to raise_error(ManifestError) do |error|
|
|
119
|
+
expect(error.message).to include("[ruact] Vite dev server unreachable at http://localhost:5173")
|
|
120
|
+
expect(error.message).to include("no react-client-manifest.json found at /no/such/manifest.json")
|
|
121
|
+
expect(error.message).to include("run `bin/dev`")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
110
125
|
it "ignores a non-2xx dev-server response and falls back" do
|
|
111
126
|
allow(Net::HTTP).to receive(:start).and_return(Net::HTTPNotFound.new("1.1", "404", "Not Found"))
|
|
112
127
|
allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
|
|
@@ -32,6 +32,132 @@ module Ruact
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Story 13.7 — `ruact_props` on ActiveRecord models.
|
|
36
|
+
#
|
|
37
|
+
# ActiveRecord defines its attribute reader methods LAZILY (on first
|
|
38
|
+
# instance access), so at the moment the `ruact_props :title` macro runs,
|
|
39
|
+
# `method_defined?(:title)` is `false` even for a real column. The eager
|
|
40
|
+
# boot check used to raise there, so an AR model that included
|
|
41
|
+
# `Ruact::Serializable` crashed at class-load. Story 13.7 makes the check
|
|
42
|
+
# HYBRID: POROs are still checked eagerly at class-load (byte-identical, see
|
|
43
|
+
# the block above), while for a lazy-attribute (AR) class the loud check is
|
|
44
|
+
# DEFERRED to the first `ruact_serialize` — where the DB is up and the reader
|
|
45
|
+
# exists. Loudness is preserved: a bogus prop still raises a clean
|
|
46
|
+
# `ArgumentError`, just at first render of that model instead of at boot.
|
|
47
|
+
#
|
|
48
|
+
# Zero suite-boot DB footprint: the connection + table are built INSIDE the
|
|
49
|
+
# example (direct connection DDL, NOT `Schema.define`, so we touch nothing
|
|
50
|
+
# global — no `schema_migrations` / `ar_internal_metadata`), and only a
|
|
51
|
+
# connection we ourselves opened is torn down.
|
|
52
|
+
describe "ActiveRecord models (Story 13.7)", :story_13_7 do
|
|
53
|
+
before do
|
|
54
|
+
require "active_record"
|
|
55
|
+
@already_connected =
|
|
56
|
+
ActiveRecord::Base.respond_to?(:connected?) && ActiveRecord::Base.connected?
|
|
57
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") unless @already_connected
|
|
58
|
+
ActiveRecord::Base.connection.create_table(:ruact_props_probe_posts, force: true) do |t|
|
|
59
|
+
t.string :title
|
|
60
|
+
t.string :secret
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
after do
|
|
65
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
66
|
+
ActiveRecord::Base.connection.drop_table(:ruact_props_probe_posts, if_exists: true)
|
|
67
|
+
end
|
|
68
|
+
ActiveRecord::Base.remove_connection unless @already_connected
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
let(:ar_model) do
|
|
72
|
+
Class.new(ActiveRecord::Base) do
|
|
73
|
+
self.table_name = "ruact_props_probe_posts"
|
|
74
|
+
include Ruact::Serializable
|
|
75
|
+
|
|
76
|
+
ruact_props :id, :title
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "loads without raising even though readers are lazy at class-load (AC#1)" do
|
|
81
|
+
expect { ar_model }.not_to raise_error
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "ruact_serialize returns only the declared allowlist (AC#1)" do
|
|
85
|
+
row = ar_model.create!(title: "Hello", secret: "top-secret")
|
|
86
|
+
|
|
87
|
+
expect(row.ruact_serialize).to eq({ "id" => row.id, "title" => "Hello" })
|
|
88
|
+
expect(row.ruact_serialize.keys).not_to include("secret")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "defers the loud check to first serialize for a bogus prop (AC#2)" do
|
|
92
|
+
model = nil
|
|
93
|
+
|
|
94
|
+
# Declaring a bogus prop on an AR model does NOT raise at class-load —
|
|
95
|
+
# the reader might still be defined lazily, so the check is deferred.
|
|
96
|
+
expect do
|
|
97
|
+
model = Class.new(ActiveRecord::Base) do
|
|
98
|
+
self.table_name = "ruact_props_probe_posts"
|
|
99
|
+
include Ruact::Serializable
|
|
100
|
+
|
|
101
|
+
ruact_props :ttile # typo — neither a column nor a method
|
|
102
|
+
end
|
|
103
|
+
end.not_to raise_error
|
|
104
|
+
|
|
105
|
+
# …but it fails LOUDLY (same clean ArgumentError) on first serialize.
|
|
106
|
+
expect { model.new.ruact_serialize }
|
|
107
|
+
.to raise_error(ArgumentError, /ttile.*not defined/)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "re-validates loudly when props are re-declared after a first serialize (AC#2)" do
|
|
111
|
+
model = Class.new(ActiveRecord::Base) do
|
|
112
|
+
self.table_name = "ruact_props_probe_posts"
|
|
113
|
+
include Ruact::Serializable
|
|
114
|
+
|
|
115
|
+
ruact_props :id, :title
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# First serialize succeeds and memoizes "validated"…
|
|
119
|
+
model.create!(title: "Hello").ruact_serialize
|
|
120
|
+
|
|
121
|
+
# …a re-declaration with a bogus prop must NOT be masked by the stale
|
|
122
|
+
# memo: the next serialize still raises the clean ArgumentError.
|
|
123
|
+
model.ruact_props :id, :ttile
|
|
124
|
+
expect { model.new.ruact_serialize }
|
|
125
|
+
.to raise_error(ArgumentError, /ttile.*not defined/)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "re-validates an inherited declaration re-declared on the parent after a subclass serialize (AC#2)" do
|
|
129
|
+
parent = Class.new(ActiveRecord::Base) do
|
|
130
|
+
self.table_name = "ruact_props_probe_posts"
|
|
131
|
+
include Ruact::Serializable
|
|
132
|
+
|
|
133
|
+
ruact_props :id, :title
|
|
134
|
+
end
|
|
135
|
+
child = Class.new(parent)
|
|
136
|
+
|
|
137
|
+
# The subclass serializes first, memoizing the inherited (valid) list…
|
|
138
|
+
parent.create!(title: "Hello")
|
|
139
|
+
child.first.ruact_serialize
|
|
140
|
+
|
|
141
|
+
# …then the PARENT re-declares with a bogus prop. The subclass must not
|
|
142
|
+
# keep serving its stale memo: its next serialize re-validates loudly.
|
|
143
|
+
parent.ruact_props :id, :ttile
|
|
144
|
+
expect { child.first.ruact_serialize }
|
|
145
|
+
.to raise_error(ArgumentError, /ttile.*not defined/)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "dispatches through serialize_serializable under strict true AND false (AC#4)" do
|
|
149
|
+
row = ar_model.create!(title: "Hello", secret: "top-secret")
|
|
150
|
+
|
|
151
|
+
[true, false].each do |strict|
|
|
152
|
+
output = Ruact::Flight::Renderer.render(
|
|
153
|
+
row, Ruact::ClientManifest.from_hash({}), strict_serialization: strict
|
|
154
|
+
)
|
|
155
|
+
expect(output).to include("Hello")
|
|
156
|
+
expect(output).not_to include("top-secret")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
35
161
|
describe "ruact_serialize (AC#1)" do
|
|
36
162
|
it "returns only declared props" do
|
|
37
163
|
obj = serializable_class.new
|