ruact 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +18 -8
  5. data/RELEASING.md +125 -139
  6. data/lib/generators/ruact/install/install_generator.rb +305 -126
  7. data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
  9. data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
  10. data/lib/generators/ruact/layout/layout_generator.rb +52 -0
  11. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
  12. data/lib/ruact/configuration.rb +65 -13
  13. data/lib/ruact/controller/document_rendering.rb +72 -16
  14. data/lib/ruact/controller/page_rendering.rb +134 -0
  15. data/lib/ruact/controller/pages.rb +116 -0
  16. data/lib/ruact/controller.rb +78 -11
  17. data/lib/ruact/doctor.rb +233 -25
  18. data/lib/ruact/layout_source.rb +29 -7
  19. data/lib/ruact/navigation_boundary.rb +240 -0
  20. data/lib/ruact/packaging.rb +68 -0
  21. data/lib/ruact/railtie.rb +30 -0
  22. data/lib/ruact/server.rb +10 -1
  23. data/lib/ruact/version.rb +1 -1
  24. data/lib/ruact/view_helper.rb +158 -1
  25. data/lib/ruact/views/layouts/ruact.html.erb +32 -0
  26. data/lib/ruact.rb +29 -0
  27. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  28. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  29. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  30. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  32. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
  33. metadata +12 -107
  34. data/.codecov.yml +0 -31
  35. data/.github/workflows/ci.yml +0 -284
  36. data/.github/workflows/server-functions-bench.yml +0 -54
  37. data/.rubocop.yml +0 -107
  38. data/.rubocop_todo.yml +0 -63
  39. data/Rakefile +0 -10
  40. data/bench/server_functions_dispatch_bench.rb +0 -276
  41. data/bench/server_functions_dispatch_bench.results.md +0 -150
  42. data/docs/internal/README.md +0 -9
  43. data/docs/internal/decisions/server-functions-api.md +0 -2236
  44. data/spec/benchmarks/baseline.json +0 -12
  45. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  46. data/spec/fixtures/flight/README.md +0 -136
  47. data/spec/fixtures/flight/array.txt +0 -1
  48. data/spec/fixtures/flight/as_json_object.txt +0 -2
  49. data/spec/fixtures/flight/bigint.txt +0 -1
  50. data/spec/fixtures/flight/boolean_false.txt +0 -1
  51. data/spec/fixtures/flight/boolean_true.txt +0 -1
  52. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  53. data/spec/fixtures/flight/client_reference.txt +0 -2
  54. data/spec/fixtures/flight/hash.txt +0 -1
  55. data/spec/fixtures/flight/infinity.txt +0 -1
  56. data/spec/fixtures/flight/nan.txt +0 -1
  57. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  58. data/spec/fixtures/flight/nil.txt +0 -1
  59. data/spec/fixtures/flight/number_float.txt +0 -1
  60. data/spec/fixtures/flight/number_integer.txt +0 -1
  61. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  62. data/spec/fixtures/flight/redirect_row.txt +0 -1
  63. data/spec/fixtures/flight/serializable_object.txt +0 -2
  64. data/spec/fixtures/flight/string_basic.txt +0 -1
  65. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  66. data/spec/fixtures/flight/undefined.txt +0 -1
  67. data/spec/fixtures/readme/children-error.html.erb +0 -3
  68. data/spec/fixtures/readme/children-error.txt +0 -1
  69. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  70. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  71. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  72. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  73. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  74. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  75. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  76. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  77. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  78. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  79. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  80. data/spec/readme_demo_message_spec.rb +0 -67
  81. data/spec/readme_spec.rb +0 -282
  82. data/spec/ruact/client_manifest_spec.rb +0 -270
  83. data/spec/ruact/component_contract_spec.rb +0 -119
  84. data/spec/ruact/configuration_spec.rb +0 -518
  85. data/spec/ruact/controller_request_spec.rb +0 -671
  86. data/spec/ruact/controller_spec.rb +0 -343
  87. data/spec/ruact/doctor_spec.rb +0 -769
  88. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  89. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  90. data/spec/ruact/errors_spec.rb +0 -93
  91. data/spec/ruact/flight/renderer_spec.rb +0 -133
  92. data/spec/ruact/flight/serializer_spec.rb +0 -494
  93. data/spec/ruact/html_converter_spec.rb +0 -375
  94. data/spec/ruact/install_generator_spec.rb +0 -1549
  95. data/spec/ruact/layout_source_spec.rb +0 -108
  96. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  97. data/spec/ruact/query_request_spec.rb +0 -706
  98. data/spec/ruact/query_spec.rb +0 -105
  99. data/spec/ruact/railtie_spec.rb +0 -155
  100. data/spec/ruact/render_context_spec.rb +0 -58
  101. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  102. data/spec/ruact/render_pipeline_spec.rb +0 -928
  103. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  104. data/spec/ruact/serializable_spec.rb +0 -179
  105. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  106. data/spec/ruact/server_function_name_spec.rb +0 -53
  107. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  108. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  109. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  110. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  111. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  112. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  113. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  114. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  115. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  116. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  117. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  118. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  119. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  120. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  121. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  122. data/spec/ruact/server_spec.rb +0 -179
  123. data/spec/ruact/server_upload_request_spec.rb +0 -311
  124. data/spec/ruact/signed_references_spec.rb +0 -164
  125. data/spec/ruact/string_distance_spec.rb +0 -38
  126. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  127. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  128. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  129. data/spec/ruact/validation_errors_spec.rb +0 -116
  130. data/spec/ruact/view_helper_spec.rb +0 -131
  131. data/spec/spec_helper.rb +0 -77
  132. data/spec/support/fixtures/pixel.png +0 -0
  133. data/spec/support/flight_wire_parser.rb +0 -21
  134. data/spec/support/flight_wire_parser_spec.rb +0 -93
  135. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  136. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  137. data/spec/support/rails_stub.rb +0 -115
@@ -1,202 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "active_support/all"
5
- require "action_dispatch"
6
- require "ruact/server_functions/route_source"
7
-
8
- module Ruact
9
- module ServerFunctions
10
- # Story 9.3 — route introspection: Rails.application.routes (filtered to
11
- # non-GET routes on controllers that include Ruact::Server) → v2 mutation
12
- # entries with the derivation table locked in the 2026-06-09 ADR addendum.
13
- RSpec.describe RouteSource, :story_9_3 do
14
- # Builds an isolated RouteSet so specs never depend on a host app's routes.
15
- def route_set(&blk)
16
- rs = ActionDispatch::Routing::RouteSet.new
17
- rs.draw(&blk)
18
- rs
19
- end
20
-
21
- # All controllers count as Ruact::Server hosts (isolates the derivation
22
- # table from constant-resolution); the filter behaviour is tested
23
- # separately below.
24
- def collect(routes, overrides: {})
25
- described_class.collect(
26
- routes,
27
- host_predicate: ->(_controller) { true },
28
- overrides_for: ->(controller) { overrides.fetch(controller, {}) }
29
- )
30
- end
31
-
32
- def ids(entries) = entries.map { |e| e["js_identifier"] }
33
-
34
- describe "derivation table (AC3)" do
35
- it "names the RESTful collection create as singular verb+resource" do
36
- rs = route_set { resources :posts, only: %i[create] }
37
- entry = collect(rs).fetch(0)
38
- expect(entry["js_identifier"]).to eq("createPost")
39
- expect(entry["http_method"]).to eq("POST")
40
- expect(entry["path"]).to eq("/posts")
41
- expect(entry["segments"]).to eq([])
42
- expect(entry["kind"]).to eq("action")
43
- end
44
-
45
- it "names update (member) singular and prefers PATCH over the PUT alias" do
46
- rs = route_set { resources :posts, only: %i[update] }
47
- entries = collect(rs)
48
- expect(entries.size).to eq(1) # PATCH + PUT collapse to one entry
49
- entry = entries.fetch(0)
50
- expect(entry["js_identifier"]).to eq("updatePost")
51
- expect(entry["http_method"]).to eq("PATCH")
52
- expect(entry["path"]).to eq("/posts/:id")
53
- expect(entry["segments"]).to eq(["id"])
54
- end
55
-
56
- it "names destroy (member) singular" do
57
- rs = route_set { resources :posts, only: %i[destroy] }
58
- entry = collect(rs).fetch(0)
59
- expect(entry["js_identifier"]).to eq("destroyPost")
60
- expect(entry["http_method"]).to eq("DELETE")
61
- expect(entry["path"]).to eq("/posts/:id")
62
- end
63
-
64
- it "skips the GET RESTful actions (index/show/new/edit)" do
65
- rs = route_set { resources :posts }
66
- expect(ids(collect(rs))).to match_array(%w[createPost updatePost destroyPost])
67
- end
68
-
69
- it "names a custom member route singular (verb-action + singular resource)" do
70
- rs = route_set do
71
- resources :posts do
72
- member { post :publish }
73
- end
74
- end
75
- entry = collect(rs).find { |e| e["js_identifier"] == "publishPost" }
76
- expect(entry).not_to be_nil
77
- expect(entry["http_method"]).to eq("POST")
78
- expect(entry["path"]).to eq("/posts/:id/publish")
79
- expect(entry["segments"]).to eq(["id"])
80
- end
81
-
82
- it "names a custom collection route plural" do
83
- rs = route_set do
84
- resources :posts do
85
- collection { post :publish_all }
86
- end
87
- end
88
- entry = collect(rs).find { |e| e["js_identifier"] == "publishAllPosts" }
89
- expect(entry).not_to be_nil
90
- expect(entry["path"]).to eq("/posts/publish_all")
91
- expect(entry["segments"]).to eq([])
92
- end
93
-
94
- it "names singular-resource create/destroy singular" do
95
- rs = route_set { resource :session, only: %i[create destroy] }
96
- expect(ids(collect(rs))).to match_array(%w[createSession destroySession])
97
- end
98
-
99
- it "prefixes namespaced controllers (verb + Namespace + Resource)" do
100
- rs = route_set { namespace(:admin) { resources :posts, only: %i[create update] } }
101
- expect(ids(collect(rs))).to match_array(%w[createAdminPost updateAdminPost])
102
- end
103
-
104
- it "deep-prefixes multi-level namespaces" do
105
- rs = route_set do
106
- namespace(:admin) { namespace(:reports) { resources :posts, only: %i[create] } }
107
- end
108
- expect(ids(collect(rs))).to eq(%w[createAdminReportsPost])
109
- end
110
-
111
- it "sorts entries by js_identifier for deterministic output" do
112
- rs = route_set { resources :posts }
113
- expect(ids(collect(rs))).to eq(%w[createPost destroyPost updatePost])
114
- end
115
-
116
- it "classifies a custom-param member route (param: :slug) as member → singular" do
117
- rs = route_set do
118
- resources :posts, param: :slug do
119
- member { post :publish }
120
- end
121
- end
122
- entry = collect(rs).find { |e| e["action"] == "publish" }
123
- expect(entry["js_identifier"]).to eq("publishPost")
124
- expect(entry["segments"]).to eq(["slug"])
125
- end
126
-
127
- it "classifies a nested collection route (only parent :id present) as collection → plural" do
128
- rs = route_set do
129
- resources :posts, only: [] do
130
- resources :comments, only: [] do
131
- collection { post :flag_all }
132
- end
133
- end
134
- end
135
- entry = collect(rs).find { |e| e["action"] == "flag_all" }
136
- expect(entry["js_identifier"]).to eq("flagAllComments")
137
- end
138
- end
139
-
140
- describe "rename override (AC4 input)" do
141
- it "uses the per-controller override identifier when present" do
142
- rs = route_set do
143
- resources :posts do
144
- collection { post :publish_all }
145
- end
146
- end
147
- entries = collect(rs, overrides: { "posts" => { "publish_all" => "publishEverything" } })
148
- expect(ids(entries)).to include("publishEverything")
149
- expect(ids(entries)).not_to include("publishAllPosts")
150
- end
151
- end
152
-
153
- describe "collision detection (AC4)" do
154
- it "fails loudly naming BOTH origins when two routes map to the same identifier" do
155
- rs = route_set do
156
- resources :posts, only: %i[create]
157
- resources :comments, only: %i[create]
158
- end
159
- # Force a collision: comments#create is renamed onto posts#create's name.
160
- overrides = { "comments" => { "create" => "createPost" } }
161
- expect do
162
- collect(rs, overrides: overrides)
163
- end.to raise_error(Ruact::ConfigurationError, /naming collision/) { |err|
164
- expect(err.message).to include("posts#create")
165
- expect(err.message).to include("comments#create")
166
- expect(err.message).to include("createPost")
167
- }
168
- end
169
-
170
- it "boot succeeds once the colliding route is renamed to a free identifier (full cycle)" do
171
- rs = route_set do
172
- resources :posts, only: %i[create]
173
- resources :comments, only: %i[create]
174
- end
175
- overrides = { "comments" => { "create" => "createComment" } }
176
- ids_out = ids(collect(rs, overrides: overrides))
177
- expect(ids_out).to eq(%w[createComment createPost])
178
- end
179
- end
180
-
181
- describe "host filter (Ruact::Server only)" do
182
- it "skips routes whose controller is not a Ruact::Server host" do
183
- rs = route_set do
184
- resources :posts, only: %i[create]
185
- resources :widgets, only: %i[create]
186
- end
187
- entries = described_class.collect(
188
- rs,
189
- host_predicate: ->(controller) { controller == "posts" },
190
- overrides_for: ->(_c) { {} }
191
- )
192
- expect(ids(entries)).to eq(%w[createPost])
193
- end
194
-
195
- it "skips GET/HEAD routes even on a host controller" do
196
- rs = route_set { get "/posts/search", to: "posts#search" }
197
- expect(collect(rs)).to be_empty
198
- end
199
- end
200
- end
201
- end
202
- end
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "tmpdir"
5
-
6
- module Ruact
7
- module ServerFunctions
8
- RSpec.describe Snapshot, :story_9_3 do
9
- let(:frozen_time) { Time.utc(2026, 5, 13, 12, 34, 56) }
10
- let(:entries) do
11
- [
12
- { "js_identifier" => "createPost", "kind" => "action", "http_method" => "POST",
13
- "path" => "/posts", "segments" => [], "controller" => "posts", "action" => "create" }
14
- ]
15
- end
16
-
17
- describe ".dump_v2" do
18
- it "wraps entries in a version-2 snapshot Hash" do
19
- snap = described_class.dump_v2(entries, now: frozen_time)
20
- expect(snap[:version]).to eq(2)
21
- expect(snap[:generated_at]).to eq(frozen_time.iso8601)
22
- expect(snap[:functions]).to eq(entries)
23
- end
24
- end
25
-
26
- describe ".generate_v2! (write-if-changed)" do
27
- around do |example|
28
- Dir.mktmpdir do |dir|
29
- @tmpdir = dir
30
- example.run
31
- end
32
- end
33
-
34
- let(:path) { File.join(@tmpdir, "server-functions.json") }
35
-
36
- it "writes a version-2 bridge on first call" do
37
- expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
38
- parsed = JSON.parse(File.read(path))
39
- expect(parsed.fetch("version")).to eq(2)
40
- expect(parsed.fetch("functions").first.fetch("js_identifier")).to eq("createPost")
41
- end
42
-
43
- it "short-circuits when entries are unchanged (no timestamp churn)" do
44
- described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
45
- expect(described_class.generate_v2!(entries: entries, path: path, now: Time.now.utc)).to be(false)
46
- end
47
-
48
- it "rewrites when entries change" do
49
- described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
50
- more = entries + [{ "js_identifier" => "destroyPost", "kind" => "action",
51
- "http_method" => "DELETE", "path" => "/posts/:id",
52
- "segments" => ["id"], "controller" => "posts", "action" => "destroy" }]
53
- expect(described_class.generate_v2!(entries: more, path: path, now: frozen_time)).to be(true)
54
- end
55
-
56
- it "creates the parent directory if missing" do
57
- nested = File.join(@tmpdir, "deep", "nested", "server-functions.json")
58
- expect(described_class.generate_v2!(entries: entries, path: nested, now: frozen_time)).to be(true)
59
- expect(File.exist?(nested)).to be(true)
60
- end
61
-
62
- it "recovers from a corrupted existing file by overwriting it" do
63
- File.write(path, "{ not json")
64
- expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
65
- expect(JSON.parse(File.read(path)).fetch("version")).to eq(2)
66
- end
67
-
68
- # Story 13.4 — the new typed-query metadata must survive the JSON
69
- # round-trip unchanged AND keep the file byte-stable (no churn on a
70
- # second pass with identical entries).
71
- context "with typed-query params metadata (Story 13.4)", :story_13_4 do
72
- let(:query_entries) do
73
- [
74
- { "js_identifier" => "searchUsers", "kind" => "query", "http_method" => "GET",
75
- "path" => "/q/searchUsers", "segments" => [], "accepts_params" => true,
76
- "params" => [{ "name" => "term", "required" => true },
77
- { "name" => "limit", "required" => false }],
78
- "params_rest" => false, "controller" => "CatalogQuery", "action" => "search_users" }
79
- ]
80
- end
81
-
82
- it "round-trips the params metadata unchanged" do
83
- described_class.generate_v2!(entries: query_entries, path: path, now: frozen_time)
84
- parsed = JSON.parse(File.read(path))
85
- expect(parsed.fetch("functions")).to eq(query_entries)
86
- end
87
-
88
- it "is byte-stable: a second pass with identical entries writes nothing" do
89
- described_class.generate_v2!(entries: query_entries, path: path, now: frozen_time)
90
- expect(described_class.generate_v2!(entries: query_entries, path: path, now: Time.now.utc)).to be(false)
91
- end
92
- end
93
- end
94
- end
95
- end
96
- end
@@ -1,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "tmpdir"
5
-
6
- module Ruact
7
- module ServerFunctions
8
- RSpec.describe SnapshotWriter, :story_8_0a do
9
- around do |example|
10
- Dir.mktmpdir do |dir|
11
- @tmpdir = dir
12
- example.run
13
- end
14
- end
15
-
16
- let(:path) { File.join(@tmpdir, "out.txt") }
17
-
18
- describe ".write_if_changed! (Story 8.0a — atomic, byte-aware writer)" do
19
- it "writes the file when it does not yet exist and returns true" do
20
- expect(described_class.write_if_changed!(path: path, content: "hello\n"))
21
- .to be(true)
22
- expect(File.read(path)).to eq("hello\n")
23
- end
24
-
25
- it "skips writing when the existing content matches byte-for-byte" do
26
- File.write(path, "hello\n")
27
- original_mtime = File.mtime(path)
28
- sleep 1.05
29
-
30
- expect(described_class.write_if_changed!(path: path, content: "hello\n"))
31
- .to be(false)
32
- expect(File.mtime(path)).to eq(original_mtime)
33
- end
34
-
35
- it "writes when the existing content differs by even a single byte" do
36
- File.write(path, "hello\n")
37
- expect(described_class.write_if_changed!(path: path, content: "hello!\n"))
38
- .to be(true)
39
- expect(File.read(path)).to eq("hello!\n")
40
- end
41
-
42
- it "creates missing parent directories" do
43
- nested = File.join(@tmpdir, "a", "b", "c", "out.txt")
44
- described_class.write_if_changed!(path: nested, content: "x")
45
- expect(File.read(nested)).to eq("x")
46
- end
47
-
48
- it "writes via a same-directory tmpfile so partial reads never see " \
49
- "a torn file (Story 8.0a)" do
50
- described_class.write_if_changed!(path: path, content: "atomic\n")
51
- # After the write the temp sibling must not linger.
52
- siblings = Dir.children(@tmpdir)
53
- expect(siblings).to eq(["out.txt"])
54
- end
55
-
56
- it "raises Ruact::ConfigurationError when the parent directory is unwritable " \
57
- "(Story 8.0a)" do
58
- read_only = File.join(@tmpdir, "ro")
59
- FileUtils.mkdir_p(read_only)
60
- FileUtils.chmod(0o500, read_only)
61
- target = File.join(read_only, "nested", "out.txt")
62
-
63
- expect { described_class.write_if_changed!(path: target, content: "x") }
64
- .to raise_error(Ruact::ConfigurationError, /cannot create/)
65
- ensure
66
- FileUtils.chmod(0o700, read_only) if read_only && File.exist?(read_only)
67
- end
68
- end
69
- end
70
- end
71
- end