ruact 0.0.4 → 0.0.6

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -3,6 +3,7 @@
3
3
  require "spec_helper"
4
4
  require "tmpdir"
5
5
  require "fileutils"
6
+ require "json"
6
7
  require "ruact"
7
8
 
8
9
  RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
@@ -215,8 +216,6 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
215
216
  let(:gitignore_entries) do
216
217
  [
217
218
  "app/javascript/.ruact/server-functions.ts",
218
- # Story 9.3 — route-driven (v2) parallel inspection target.
219
- "app/javascript/.ruact/server-functions.next.ts",
220
219
  "tmp/cache/ruact/"
221
220
  ]
222
221
  end
@@ -263,8 +262,10 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
263
262
  append_gitignore_entries(tmpdir)
264
263
  content = read_file(".gitignore")
265
264
  expect(content).to include("app/javascript/.ruact/server-functions.ts")
266
- expect(content).to include("app/javascript/.ruact/server-functions.next.ts")
267
265
  expect(content).to include("tmp/cache/ruact/")
266
+ # Story 9.9 — the v1 parallel `.next` target was demolished; the
267
+ # generator must no longer scaffold its gitignore entry.
268
+ expect(content).not_to include("server-functions.next.ts")
268
269
  end
269
270
 
270
271
  it "is idempotent — running twice does not duplicate entries (Story 8.0a — pitfall #5)" do
@@ -279,7 +280,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
279
280
 
280
281
  it "does not write to .gitignore when both entries already exist" do
281
282
  write_file(".gitignore", "/tmp\napp/javascript/.ruact/server-functions.ts\n" \
282
- "app/javascript/.ruact/server-functions.next.ts\ntmp/cache/ruact/\n")
283
+ "tmp/cache/ruact/\n")
283
284
  result = append_gitignore_entries(tmpdir)
284
285
  expect(result).to eq(:already_present)
285
286
  end
@@ -301,5 +302,591 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
301
302
  expect(content).to include("app/javascript/.ruact/server-functions.ts")
302
303
  end
303
304
  end
305
+
306
+ # REGRESSION (Sprint Change Proposal 2026-06-16 §4.5): a fresh `rails new`
307
+ # crashed `rails generate ruact:install` on Rails 8.1 / Thor because the
308
+ # generator called `destination_root.join(...)` — Thor returns
309
+ # `destination_root` as a String (`File.expand_path`), which has no
310
+ # path-style `#join`, so the installer raised `NoMethodError` before writing
311
+ # a single file. The fix wraps every site in `Pathname(destination_root)`.
312
+ #
313
+ # The "generator action helpers" tests above reimplement the file logic with
314
+ # `File.join`, so they never run Thor's path handling and masked the bug.
315
+ # These tests invoke the REAL generator against a String destination_root, so
316
+ # a revert of the Pathname fix fails loudly here.
317
+ describe "real generator invocation against a String destination_root" do
318
+ require "stringio"
319
+ require "generators/ruact/install/install_generator"
320
+
321
+ let(:app_root) { Dir.mktmpdir("ruact_install_real") }
322
+
323
+ after { FileUtils.rm_rf(app_root) }
324
+
325
+ def build_generator(root)
326
+ Ruact::Generators::InstallGenerator.new([], {}, destination_root: root)
327
+ end
328
+
329
+ def silently
330
+ original = $stdout
331
+ $stdout = StringIO.new
332
+ yield
333
+ ensure
334
+ $stdout = original
335
+ end
336
+
337
+ before do
338
+ FileUtils.mkdir_p(File.join(app_root, "app/controllers"))
339
+ File.write(File.join(app_root, "app/controllers/application_controller.rb"),
340
+ "class ApplicationController < ActionController::Base\nend\n")
341
+ FileUtils.mkdir_p(File.join(app_root, "app/views/layouts"))
342
+ File.write(File.join(app_root, "app/views/layouts/application.html.erb"),
343
+ "<!DOCTYPE html>\n<html>\n <body>\n <%= yield %>\n </body>\n</html>\n")
344
+ File.write(File.join(app_root, ".gitignore"), "/log/*\n")
345
+ end
346
+
347
+ it "hands the generator a String destination_root (the exact crashing condition)" do
348
+ expect(build_generator(app_root).destination_root).to be_a(String)
349
+ end
350
+
351
+ it "runs every path-touching action without raising and writes the files", :aggregate_failures do
352
+ gen = build_generator(app_root)
353
+
354
+ silently do
355
+ expect do
356
+ gen.inject_controller_concern
357
+ gen.inject_layout_shell
358
+ gen.create_components_directory
359
+ gen.create_server_functions_directory
360
+ gen.append_gitignore_entries
361
+ gen.create_vite_config
362
+ end.not_to raise_error
363
+ end
364
+
365
+ expect(File.read(File.join(app_root, "app/controllers/application_controller.rb")))
366
+ .to include("include Ruact::Controller")
367
+ expect(File.read(File.join(app_root, "app/views/layouts/application.html.erb")))
368
+ .to include('<div id="root"></div>')
369
+ expect(File).to exist(File.join(app_root, "app/javascript/components/.keep"))
370
+ expect(File).to exist(File.join(app_root, "app/javascript/.ruact/.gitkeep"))
371
+ expect(File.read(File.join(app_root, ".gitignore")))
372
+ .to include("app/javascript/.ruact/server-functions.ts")
373
+ expect(File).to exist(File.join(app_root, "vite.config.js"))
374
+ end
375
+ end
376
+ end
377
+
378
+ # Story 14.1 (FR101) — one-command install: `ruact:install` now runs
379
+ # `npm install` by default (behind a stubbable `run_npm_install` seam),
380
+ # skippable via `--skip-npm`, with an npm-on-PATH guard and outcome-aware
381
+ # post-install messaging. The shell-out is ALWAYS stubbed here — no real
382
+ # npm / network call is made in CI. Reuses the "real generator against a
383
+ # String destination_root" pattern (instantiate + call action methods).
384
+ describe "install generator — one-command npm install (Story 14.1 — FR101)", :story_14_1 do
385
+ require "stringio"
386
+ require "generators/ruact/install/install_generator"
387
+
388
+ let(:app_root) { Dir.mktmpdir("ruact_install_npm") }
389
+
390
+ after { FileUtils.rm_rf(app_root) }
391
+
392
+ def build_generator(root, opts = {})
393
+ Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
394
+ end
395
+
396
+ def silently
397
+ original = $stdout
398
+ $stdout = StringIO.new
399
+ yield
400
+ ensure
401
+ $stdout = original
402
+ end
403
+
404
+ # Captures and returns the generator's say/say_status stdout for text asserts.
405
+ def capture_stdout
406
+ original = $stdout
407
+ $stdout = StringIO.new
408
+ yield
409
+ $stdout.string
410
+ ensure
411
+ $stdout = original
412
+ end
413
+
414
+ describe "--skip-npm class option (AC#2)", :aggregate_failures do
415
+ it "is registered as a boolean class_option defaulting to false" do
416
+ option = Ruact::Generators::InstallGenerator.class_options[:skip_npm]
417
+ expect(option).not_to be_nil
418
+ expect(option.type).to eq(:boolean)
419
+ expect(option.default).to be false
420
+ end
421
+ end
422
+
423
+ describe "default run installs JS deps (AC#1, AC#6a)" do
424
+ it "invokes the run_npm_install seam exactly once" do
425
+ gen = build_generator(app_root)
426
+ allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: true)
427
+
428
+ silently { gen.install_javascript_dependencies }
429
+
430
+ expect(gen).to have_received(:run_npm_install).once
431
+ expect(gen.instance_variable_get(:@npm_outcome)).to eq(:installed)
432
+ end
433
+
434
+ it "runs the npm step AFTER the file-producing actions and BEFORE show_post_install_message",
435
+ :aggregate_failures do
436
+ # Thor runs public action methods in SOURCE definition order, so assert
437
+ # on source line numbers (public_instance_methods order is not
438
+ # guaranteed to match definition order). Story 14.2 removed
439
+ # create_javascript_entry; create_vite_config is the last file-writing
440
+ # action before the npm step.
441
+ klass = Ruact::Generators::InstallGenerator
442
+ line = ->(name) { klass.instance_method(name).source_location.last }
443
+ expect(line.call(:create_vite_config)).to be < line.call(:install_javascript_dependencies)
444
+ expect(line.call(:install_javascript_dependencies)).to be < line.call(:show_post_install_message)
445
+ end
446
+ end
447
+
448
+ describe "run_npm_install seam (AC#6a)", :aggregate_failures do
449
+ it "shells out `npm install` inside the destination_root" do
450
+ gen = build_generator(app_root)
451
+ allow(gen).to receive(:inside).and_yield
452
+ allow(gen).to receive(:run)
453
+
454
+ silently { gen.send(:run_npm_install) }
455
+
456
+ expect(gen).to have_received(:inside).with(gen.destination_root)
457
+ expect(gen).to have_received(:run).with("npm install")
458
+ end
459
+ end
460
+
461
+ describe "--skip-npm opts out (AC#2, AC#6b)" do
462
+ it "does not invoke the run_npm_install seam and emits a skip notice" do
463
+ gen = build_generator(app_root, { skip_npm: true })
464
+ allow(gen).to receive(:run_npm_install)
465
+
466
+ output = capture_stdout { gen.install_javascript_dependencies }
467
+
468
+ expect(gen).not_to have_received(:run_npm_install)
469
+ expect(output).to match(/skip/i)
470
+ end
471
+ end
472
+
473
+ describe "--pretend dry run", :aggregate_failures do
474
+ # Thor's `run` returns nil under --pretend (the command is previewed, not
475
+ # executed) — that must NOT be misread as an npm failure.
476
+ it "treats a pretend run as a no-op, not an npm failure" do
477
+ gen = build_generator(app_root, { pretend: true })
478
+ allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: nil)
479
+
480
+ run_output = capture_stdout { gen.install_javascript_dependencies }
481
+
482
+ expect(gen.instance_variable_get(:@npm_outcome)).to eq(:pretend)
483
+ expect(run_output).not_to match(/did not complete|reported a failure/i)
484
+ end
485
+ end
486
+
487
+ describe "npm not on PATH (AC#5)", :aggregate_failures do
488
+ it "does not raise, skips the seam, and prints an actionable message naming --skip-npm" do
489
+ gen = build_generator(app_root)
490
+ allow(gen).to receive(:npm_on_path?).and_return(false)
491
+ allow(gen).to receive(:run_npm_install)
492
+
493
+ output = nil
494
+ expect { output = capture_stdout { gen.install_javascript_dependencies } }
495
+ .not_to raise_error
496
+ expect(gen).not_to have_received(:run_npm_install)
497
+ expect(output).to include("--skip-npm")
498
+ expect(output).to match(/npm/i)
499
+ expect(output).to match(/node/i)
500
+ end
501
+ end
502
+
503
+ describe "post-install message reflects the outcome (AC#3)", :aggregate_failures do
504
+ it "states deps are installed and next step is bin/dev when npm ran" do
505
+ gen = build_generator(app_root)
506
+ allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: true)
507
+ silently { gen.install_javascript_dependencies }
508
+
509
+ output = capture_stdout { gen.show_post_install_message }
510
+ expect(output).to include("are installed")
511
+ expect(output).not_to include("not yet installed")
512
+ expect(output).to include("bin/dev")
513
+ end
514
+
515
+ # AC#3 — Thor's `run` returns false on a non-zero exit (generators do not
516
+ # exit_on_failure?), so a FAILED `npm install` must NOT report success.
517
+ it "does NOT claim deps are installed when npm install fails (AC#3)" do
518
+ gen = build_generator(app_root)
519
+ allow(gen).to receive_messages(npm_on_path?: true, run_npm_install: false)
520
+
521
+ run_output = capture_stdout { gen.install_javascript_dependencies }
522
+ message = capture_stdout { gen.show_post_install_message }
523
+
524
+ expect(gen.instance_variable_get(:@npm_outcome)).to eq(:failed)
525
+ expect(run_output).to match(/did not complete|failure/i)
526
+ expect(message).not_to include("are installed")
527
+ expect(message).to include("not yet installed")
528
+ expect(message).to include("bin/dev")
529
+ end
530
+
531
+ it "tells the developer to install manually then bin/dev when skipped" do
532
+ gen = build_generator(app_root, { skip_npm: true })
533
+ silently { gen.install_javascript_dependencies }
534
+
535
+ output = capture_stdout { gen.show_post_install_message }
536
+ expect(output).to include("not yet installed")
537
+ expect(output).to include("npm install")
538
+ expect(output).to include("bin/dev")
539
+ end
540
+ end
541
+ end
542
+
543
+ # Story 14.2 (FR104) — the generator no longer leaks ruact plumbing into the
544
+ # user's tree. After a fresh install, `app/javascript/` holds only the user's
545
+ # `components/` (+ the gitignored typed `.ruact/server-functions.ts`); the
546
+ # bootstrap entry is the virtual module `virtual:ruact/bootstrap`, and
547
+ # `flight-client.js` / `ruact-router.js` live inside the gem.
548
+ describe "install generator — hidden plumbing, no app/javascript leak (Story 14.2 — FR104)", :story_14_2 do
549
+ require "stringio"
550
+ require "generators/ruact/install/install_generator"
551
+
552
+ let(:app_root) { Dir.mktmpdir("ruact_install_1402") }
553
+
554
+ after { FileUtils.rm_rf(app_root) }
555
+
556
+ def build_generator(root, opts = {})
557
+ Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
558
+ end
559
+
560
+ def silently
561
+ original = $stdout
562
+ $stdout = StringIO.new
563
+ yield
564
+ ensure
565
+ $stdout = original
566
+ end
567
+
568
+ before do
569
+ FileUtils.mkdir_p(File.join(app_root, "app/controllers"))
570
+ File.write(File.join(app_root, "app/controllers/application_controller.rb"),
571
+ "class ApplicationController < ActionController::Base\nend\n")
572
+ FileUtils.mkdir_p(File.join(app_root, "app/views/layouts"))
573
+ File.write(File.join(app_root, "app/views/layouts/application.html.erb"),
574
+ "<!DOCTYPE html>\n<html>\n <body>\n <%= yield %>\n </body>\n</html>\n")
575
+ File.write(File.join(app_root, ".gitignore"), "/log/*\n")
576
+ end
577
+
578
+ it "does NOT define a create_javascript_entry action (no application.jsx writer)" do
579
+ expect(Ruact::Generators::InstallGenerator.instance_methods).not_to include(:create_javascript_entry)
580
+ end
581
+
582
+ it "no longer ships the application.jsx template (its source moved into the gem runtime)" do
583
+ template = File.expand_path(
584
+ "../../lib/generators/ruact/install/templates/application.jsx.tt", __dir__
585
+ )
586
+ expect(File).not_to exist(template)
587
+ end
588
+
589
+ it "writes no application.jsx / flight-client.js / ruact-router.js into app/javascript", :aggregate_failures do
590
+ gen = build_generator(app_root)
591
+ silently do
592
+ gen.create_components_directory
593
+ gen.create_server_functions_directory
594
+ gen.append_gitignore_entries
595
+ gen.create_vite_config
596
+ end
597
+
598
+ expect(File).not_to exist(File.join(app_root, "app/javascript/application.jsx"))
599
+ expect(File).not_to exist(File.join(app_root, "app/javascript/flight-client.js"))
600
+ expect(File).not_to exist(File.join(app_root, "app/javascript/ruact-router.js"))
601
+ # The user's components dir + the typed registry scaffold still exist.
602
+ expect(File).to exist(File.join(app_root, "app/javascript/components/.keep"))
603
+ expect(File).to exist(File.join(app_root, "app/javascript/.ruact/.gitkeep"))
604
+ end
605
+
606
+ it "keeps the .ruact/server-functions.ts gitignore entry unchanged (server-functions.ts stays)" do
607
+ gen = build_generator(app_root)
608
+ silently { gen.append_gitignore_entries }
609
+ expect(File.read(File.join(app_root, ".gitignore")))
610
+ .to include("app/javascript/.ruact/server-functions.ts")
611
+ end
612
+
613
+ describe "migration advisory for an earlier-layout app (AC7 — no half-wired state)" do
614
+ def capture_stdout
615
+ original = $stdout
616
+ $stdout = StringIO.new
617
+ yield
618
+ $stdout.string
619
+ ensure
620
+ $stdout = original
621
+ end
622
+
623
+ it "prints the exact delete steps + virtual entry when stale plumbing files exist", :aggregate_failures do
624
+ %w[application.jsx flight-client.js ruact-router.js].each do |f|
625
+ FileUtils.mkdir_p(File.join(app_root, "app/javascript"))
626
+ File.write(File.join(app_root, "app/javascript", f), "// stale\n")
627
+ end
628
+ gen = build_generator(app_root)
629
+ output = capture_stdout { gen.advise_plumbing_migration }
630
+
631
+ expect(output).to match(/earlier ruact layout detected/i)
632
+ expect(output).to include("delete app/javascript/application.jsx")
633
+ expect(output).to include("delete app/javascript/flight-client.js")
634
+ expect(output).to include("delete app/javascript/ruact-router.js")
635
+ expect(output).to include(described_class.bootstrap_virtual_id)
636
+ expect(output).to include("ruact_js_assets")
637
+ end
638
+
639
+ it "is a no-op on a fresh install (no stale files → no notice)" do
640
+ gen = build_generator(app_root)
641
+ output = capture_stdout { gen.advise_plumbing_migration }
642
+ expect(output).to be_empty
643
+ end
644
+ end
645
+
646
+ describe "vite.config input targets the virtual bootstrap (AC3 — single source of truth)" do
647
+ let(:template_path) do
648
+ File.expand_path("../../lib/generators/ruact/install/templates/vite.config.js.tt", __dir__)
649
+ end
650
+
651
+ it "renders the input from Ruact.bootstrap_virtual_id, not a hardcoded application.jsx", :aggregate_failures do
652
+ content = File.read(template_path)
653
+ expect(content).to include("Ruact.bootstrap_virtual_id")
654
+ # The `input:` line must NOT hardcode the old application.jsx entry
655
+ # (a prose mention in a comment is fine — only the directive matters).
656
+ expect(content).not_to match(%r{input:\s*['"]app/javascript/application\.jsx['"]})
657
+ end
658
+
659
+ it "the generated input equals the id the ViewHelper/prod manifest lookup uses (no drift)" do
660
+ gen = build_generator(app_root)
661
+ silently { gen.create_vite_config }
662
+ generated = File.read(File.join(app_root, "vite.config.js"))
663
+ expect(generated).to include("input: '#{described_class.bootstrap_virtual_id}'")
664
+ end
665
+
666
+ it "leaves an existing vite.config.js untouched when --force is not passed" do
667
+ File.write(File.join(app_root, "vite.config.js"), "// hand-written\n")
668
+ gen = build_generator(app_root)
669
+ silently { gen.create_vite_config }
670
+ expect(File.read(File.join(app_root, "vite.config.js"))).to eq("// hand-written\n")
671
+ end
672
+
673
+ it "actually regenerates an existing vite.config.js under --force (the message's promise)", :aggregate_failures do
674
+ File.write(File.join(app_root, "vite.config.js"), "// hand-written\n")
675
+ gen = build_generator(app_root, { force: true })
676
+ silently { gen.create_vite_config }
677
+ regenerated = File.read(File.join(app_root, "vite.config.js"))
678
+ expect(regenerated).not_to include("hand-written")
679
+ expect(regenerated).to include("input: '#{described_class.bootstrap_virtual_id}'")
680
+ end
681
+ end
682
+ end
683
+
684
+ # Story 14.6 (FR101, Epic 14 DoD) — `ruact:install` now emits the missing
685
+ # launch pieces so the literal `bin/dev` produces a working app: a
686
+ # `package.json` (so 14.1's `npm install` resolves React + Vite) and a
687
+ # `Procfile.dev` + foreman `bin/dev` that boot BOTH Rails and the Vite dev
688
+ # server. Each is guarded (non-clobbering, --force overwrites) and idempotent.
689
+ describe "install generator — launch files: package.json + Procfile.dev + bin/dev (Story 14.6)",
690
+ :story_14_6 do
691
+ require "stringio"
692
+ require "generators/ruact/install/install_generator"
693
+
694
+ let(:app_root) { Dir.mktmpdir("ruact_install_1406") }
695
+
696
+ after { FileUtils.rm_rf(app_root) }
697
+
698
+ def build_generator(root, opts = {})
699
+ Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
700
+ end
701
+
702
+ def silently
703
+ original = $stdout
704
+ $stdout = StringIO.new
705
+ yield
706
+ ensure
707
+ $stdout = original
708
+ end
709
+
710
+ describe "create_package_json (AC#1 — JS deps to install)", :aggregate_failures do
711
+ it "writes a package.json declaring React + Vite (and a `dev` script)" do
712
+ gen = build_generator(app_root)
713
+ silently { gen.create_package_json }
714
+
715
+ path = File.join(app_root, "package.json")
716
+ expect(File).to exist(path)
717
+ pkg = JSON.parse(File.read(path))
718
+ expect(pkg.dig("dependencies", "react")).to be_a(String)
719
+ expect(pkg.dig("dependencies", "react-dom")).to be_a(String)
720
+ expect(pkg.dig("devDependencies", "vite")).to be_a(String)
721
+ expect(pkg.dig("devDependencies", "@vitejs/plugin-react")).to be_a(String)
722
+ expect(pkg.dig("scripts", "dev")).to eq("vite")
723
+ expect(pkg["type"]).to eq("module")
724
+ end
725
+
726
+ it "does NOT list the bundled ruact Vite plugin as an npm dependency " \
727
+ "(vite.config imports it by absolute path)" do
728
+ gen = build_generator(app_root)
729
+ silently { gen.create_package_json }
730
+ expect(File.read(File.join(app_root, "package.json"))).not_to include("vite-plugin-ruact")
731
+ end
732
+
733
+ it "derives a valid lowercase npm name from the app directory" do
734
+ gen = build_generator(app_root)
735
+ silently { gen.create_package_json }
736
+ name = JSON.parse(File.read(File.join(app_root, "package.json")))["name"]
737
+ expect(name).to match(/\A[a-z0-9._-]+\z/)
738
+ end
739
+
740
+ it "leaves an existing package.json untouched without --force (non-clobbering)" do
741
+ File.write(File.join(app_root, "package.json"), %({ "name": "mine" }\n))
742
+ gen = build_generator(app_root)
743
+ silently { gen.create_package_json }
744
+ expect(File.read(File.join(app_root, "package.json"))).to eq(%({ "name": "mine" }\n))
745
+ end
746
+
747
+ it "overwrites an existing package.json under --force" do
748
+ File.write(File.join(app_root, "package.json"), %({ "name": "mine" }\n))
749
+ gen = build_generator(app_root, { force: true })
750
+ silently { gen.create_package_json }
751
+ expect(File.read(File.join(app_root, "package.json"))).to include("\"vite\"")
752
+ end
753
+ end
754
+
755
+ describe "create_launch_files (Epic DoD — bin/dev boots both processes)", :aggregate_failures do
756
+ it "writes Procfile.dev with BOTH a Rails web process and a Vite process" do
757
+ gen = build_generator(app_root)
758
+ silently { gen.create_launch_files }
759
+
760
+ procfile = File.read(File.join(app_root, "Procfile.dev"))
761
+ expect(procfile).to match(/^web:.*rails server/)
762
+ expect(procfile).to match(/^vite:.*npm run dev/)
763
+ end
764
+
765
+ it "writes an executable bin/dev that execs foreman against Procfile.dev" do
766
+ gen = build_generator(app_root)
767
+ silently { gen.create_launch_files }
768
+
769
+ dev = File.join(app_root, "bin/dev")
770
+ expect(File).to exist(dev)
771
+ expect(File).to be_executable(dev)
772
+ body = File.read(dev)
773
+ expect(body).to include("foreman start -f Procfile.dev")
774
+ expect(body).to include("gem install foreman")
775
+ end
776
+
777
+ # Live clean-room fix — ruact OWNS bin/dev. `rails new` (Rails 8.x) writes a
778
+ # bin/dev that starts ONLY `rails server`; left in place, Vite never runs,
779
+ # the client manifest is never written, and the first render 500s. So the
780
+ # foreman launcher must TAKE OVER that default rather than skip it.
781
+ it "takes ownership of the Rails-default bin/dev (one that only runs rails server)" do
782
+ FileUtils.mkdir_p(File.join(app_root, "bin"))
783
+ File.write(File.join(app_root, "bin/dev"),
784
+ %(#!/usr/bin/env ruby\nexec "./bin/rails", "server", *ARGV\n))
785
+
786
+ gen = build_generator(app_root)
787
+ silently { gen.create_launch_files }
788
+
789
+ body = File.read(File.join(app_root, "bin/dev"))
790
+ expect(body).to include("foreman start -f Procfile.dev")
791
+ expect(body).not_to include('exec "./bin/rails", "server"')
792
+ expect(File).to be_executable(File.join(app_root, "bin/dev"))
793
+ end
794
+
795
+ # Idempotency (invariant 14.1) — re-running the generator must not churn:
796
+ # the bin/dev we wrote already drives Procfile.dev, so the second run skips
797
+ # it. (Detection is content-based: our launcher execs foreman against
798
+ # Procfile.dev, so a marker comment proves the file was NOT rewritten.)
799
+ it "is idempotent — a second run does not rewrite the foreman bin/dev it already wrote" do
800
+ gen = build_generator(app_root)
801
+ silently { gen.create_launch_files }
802
+
803
+ marked = "#{File.read(File.join(app_root, 'bin/dev'))}# ruact-idempotency-marker\n"
804
+ File.write(File.join(app_root, "bin/dev"), marked)
805
+
806
+ gen2 = build_generator(app_root)
807
+ silently { gen2.create_launch_files }
808
+ expect(File.read(File.join(app_root, "bin/dev"))).to include("# ruact-idempotency-marker")
809
+ end
810
+
811
+ # Codex R1 — detection must be stricter than a bare `include?("Procfile.dev")`:
812
+ # a bin/dev that only NAMES Procfile.dev in a comment (but still runs only
813
+ # `rails server`) must be taken over, not skipped — else the 500 survives.
814
+ it "takes ownership when Procfile.dev appears only in a comment (runs only rails server)" do
815
+ FileUtils.mkdir_p(File.join(app_root, "bin"))
816
+ File.write(File.join(app_root, "bin/dev"),
817
+ %(#!/usr/bin/env ruby\n# TODO: switch to Procfile.dev + foreman one day\n) +
818
+ %(exec "./bin/rails", "server", *ARGV\n))
819
+
820
+ gen = build_generator(app_root)
821
+ silently { gen.create_launch_files }
822
+
823
+ body = File.read(File.join(app_root, "bin/dev"))
824
+ expect(body).to include("foreman start -f Procfile.dev")
825
+ expect(body).not_to include('exec "./bin/rails", "server"')
826
+ end
827
+
828
+ # Codex R2/R3 — a runner name that is only MENTIONED (not invoked as the
829
+ # command) must not count as a real launcher. Each of these bin/dev files
830
+ # names "foreman"/"Procfile.dev" somewhere but actually runs `rails server`,
831
+ # so each must be taken over (else the original 500 survives).
832
+ [
833
+ ["echoed in a string",
834
+ %(echo "switch to: foreman start -f Procfile.dev"\nexec ./bin/rails server\n)],
835
+ ["assigned to a variable",
836
+ %(MSG="switch to: foreman start -f Procfile.dev"\nexec ./bin/rails server\n)],
837
+ ["named inside a command -v test",
838
+ %(if command -v foreman; then echo Procfile.dev; fi\nexec ./bin/rails server\n)],
839
+ ["referenced only in an inline comment on a runner diagnostic line",
840
+ %(foreman --version # TODO: switch to Procfile.dev\nexec ./bin/rails server\n)],
841
+ ["a runner look-alike command (not the real runner)",
842
+ %(foreman-old start -f Procfile.dev\nexec ./bin/rails server\n)]
843
+ ].each do |(label, contents)|
844
+ it "takes ownership when a foreman command is only #{label} (still runs rails server)" do
845
+ FileUtils.mkdir_p(File.join(app_root, "bin"))
846
+ File.write(File.join(app_root, "bin/dev"), "#!/usr/bin/env bash\n#{contents}")
847
+
848
+ gen = build_generator(app_root)
849
+ silently { gen.create_launch_files }
850
+
851
+ body = File.read(File.join(app_root, "bin/dev"))
852
+ expect(body).to include("gem install foreman")
853
+ expect(body).not_to include("exec ./bin/rails server")
854
+ end
855
+ end
856
+
857
+ # Non-clobbering of a DELIBERATE foreman launcher — a developer's own
858
+ # bin/dev that already drives Procfile.dev is ruact-compatible and is left
859
+ # untouched (it boots Vite via the same Procfile.dev ruact writes).
860
+ it "leaves a developer's existing foreman launcher (driving Procfile.dev) untouched" do
861
+ FileUtils.mkdir_p(File.join(app_root, "bin"))
862
+ File.write(File.join(app_root, "bin/dev"),
863
+ "#!/usr/bin/env bash\n# my custom launcher\nexec foreman start -f Procfile.dev\n")
864
+
865
+ gen = build_generator(app_root)
866
+ silently { gen.create_launch_files }
867
+ expect(File.read(File.join(app_root, "bin/dev"))).to include("# my custom launcher")
868
+ end
869
+
870
+ it "overwrites even a Procfile.dev-driving launcher under --force" do
871
+ FileUtils.mkdir_p(File.join(app_root, "bin"))
872
+ File.write(File.join(app_root, "bin/dev"),
873
+ "#!/usr/bin/env bash\n# my custom launcher\nexec foreman start -f Procfile.dev\n")
874
+
875
+ gen = build_generator(app_root, { force: true })
876
+ silently { gen.create_launch_files }
877
+ body = File.read(File.join(app_root, "bin/dev"))
878
+ expect(body).to include("gem install foreman")
879
+ expect(body).not_to include("# my custom launcher")
880
+ end
881
+ end
882
+
883
+ describe "action ordering (npm install needs package.json on disk first)" do
884
+ it "defines create_package_json BEFORE install_javascript_dependencies", :aggregate_failures do
885
+ klass = Ruact::Generators::InstallGenerator
886
+ line = ->(name) { klass.instance_method(name).source_location.last }
887
+ expect(line.call(:create_package_json)).to be < line.call(:install_javascript_dependencies)
888
+ expect(line.call(:create_launch_files)).to be < line.call(:install_javascript_dependencies)
889
+ end
890
+ end
304
891
  end
305
892
  end