panda-core 0.11.0 → 0.12.5

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/app/assets/tailwind/application.css +196 -109
  4. data/app/builders/panda/core/form_builder.rb +9 -9
  5. data/app/components/panda/core/UI/button.rb +4 -4
  6. data/app/components/panda/core/admin/button_component.rb +2 -2
  7. data/app/components/panda/core/admin/form_input_component.rb +2 -2
  8. data/app/components/panda/core/admin/form_select_component.rb +2 -2
  9. data/app/components/panda/core/admin/panel_component.rb +1 -1
  10. data/app/components/panda/core/admin/statistics_component.rb +3 -3
  11. data/app/components/panda/core/admin/tab_bar_component.rb +3 -3
  12. data/app/components/panda/core/admin/table_component.rb +3 -3
  13. data/app/controllers/panda/core/admin/sessions_controller.rb +4 -1
  14. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +1 -1
  15. data/app/models/panda/core/user.rb +23 -14
  16. data/app/views/layouts/panda/core/admin.html.erb +10 -1
  17. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +3 -3
  18. data/app/views/panda/core/admin/sessions/new.html.erb +2 -3
  19. data/app/views/panda/core/admin/shared/_sidebar.html.erb +5 -5
  20. data/config/brakeman.ignore +51 -0
  21. data/db/migrate/20250809000001_create_panda_core_users.rb +1 -1
  22. data/db/migrate/20251203100000_rename_is_admin_to_admin_in_panda_core_users.rb +18 -0
  23. data/lib/panda/core/asset_loader.rb +1 -1
  24. data/lib/panda/core/configuration.rb +4 -0
  25. data/lib/panda/core/engine/admin_controller_config.rb +6 -2
  26. data/lib/panda/core/engine/autoload_config.rb +9 -10
  27. data/lib/panda/core/engine/omniauth_config.rb +92 -34
  28. data/lib/panda/core/engine/route_config.rb +37 -0
  29. data/lib/panda/core/engine.rb +46 -41
  30. data/lib/panda/core/middleware.rb +146 -0
  31. data/lib/panda/core/module_registry.rb +21 -13
  32. data/lib/panda/core/testing/rails_helper.rb +17 -8
  33. data/lib/panda/core/testing/support/authentication_helpers.rb +6 -6
  34. data/lib/panda/core/testing/support/authentication_test_helpers.rb +2 -12
  35. data/lib/panda/core/testing/support/system/browser_console_logger.rb +1 -2
  36. data/lib/panda/core/testing/support/system/chrome_path.rb +38 -0
  37. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +79 -0
  38. data/lib/panda/core/testing/support/system/cuprite_setup.rb +61 -66
  39. data/lib/panda/core/testing/support/system/system_test_helpers.rb +11 -11
  40. data/lib/panda/core/version.rb +1 -1
  41. data/lib/panda/core.rb +11 -0
  42. data/lib/tasks/panda/core/users.rake +3 -3
  43. data/lib/tasks/panda/shared.rake +31 -5
  44. data/public/panda-core-assets/favicons/browserconfig.xml +1 -1
  45. data/public/panda-core-assets/favicons/site.webmanifest +1 -1
  46. data/public/panda-core-assets/panda-core-0.11.0.css +2 -0
  47. data/public/panda-core-assets/panda-core-0.12.2.css +2 -0
  48. data/public/panda-core-assets/panda-core-0.12.3.css +2 -0
  49. data/public/panda-core-assets/panda-core.css +2 -2
  50. metadata +10 -8
  51. data/lib/panda/core/engine/middleware_config.rb +0 -17
  52. data/lib/panda/core/testing/support/system/better_system_tests.rb +0 -180
  53. data/lib/panda/core/testing/support/system/capybara_config.rb +0 -64
  54. data/lib/panda/core/testing/support/system/ci_capybara_config.rb +0 -77
  55. data/public/panda-core-assets/panda-core-0.10.6.css +0 -2
  56. data/public/panda-core-assets/panda-core-0.10.7.css +0 -2
@@ -1,82 +1,86 @@
1
- require "rubygems"
2
- require "stringio"
3
- require "rails/engine"
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
4
  require "omniauth"
5
5
 
6
- # Silence ActiveSupport::Configurable deprecation from omniauth-rails_csrf_protection
7
- # This gem uses the deprecated module but hasn't been updated yet
8
- # Issue: https://github.com/cookpad/omniauth-rails_csrf_protection/issues/23
9
- # This can be removed once the gem is updated or Rails 8.2 is released
10
- #
11
- # We suppress the warning by temporarily redirecting stderr since
12
- # ActiveSupport::Deprecation.silence was removed in Rails 8.1
13
- original_stderr = $stderr
14
- $stderr = StringIO.new
15
- begin
16
- require "omniauth/rails_csrf_protection"
17
- ensure
18
- $stderr = original_stderr
19
- end
6
+ require "panda/core/middleware"
7
+ require "panda/core/module_registry"
20
8
 
21
- # Load shared configuration modules
9
+ # Shared engine mixins
22
10
  require_relative "shared/inflections_config"
23
11
  require_relative "shared/generator_config"
24
12
 
25
- # Load engine configuration modules
13
+ # Engine mixins
26
14
  require_relative "engine/autoload_config"
27
- require_relative "engine/middleware_config"
28
15
  require_relative "engine/importmap_config"
29
16
  require_relative "engine/omniauth_config"
30
17
  require_relative "engine/phlex_config"
31
18
  require_relative "engine/admin_controller_config"
32
-
33
- # Load module registry
34
- require_relative "module_registry"
19
+ require_relative "engine/route_config"
35
20
 
36
21
  module Panda
37
22
  module Core
38
23
  class Engine < ::Rails::Engine
39
24
  isolate_namespace Panda::Core
40
25
 
41
- # Include shared configuration modules
26
+ #
27
+ # Include shared behaviours
28
+ #
42
29
  include Shared::InflectionsConfig
43
30
  include Shared::GeneratorConfig
44
31
 
45
- # Include engine-specific configuration modules
32
+ #
33
+ # Include engine-level concerns
34
+ #
46
35
  include AutoloadConfig
47
- include MiddlewareConfig
48
36
  include ImportmapConfig
49
37
  include OmniauthConfig
50
38
  include PhlexConfig
51
39
  include AdminControllerConfig
40
+ include RouteConfig
52
41
 
53
- initializer "panda_core.config" do |app|
54
- # Configuration is already initialized with defaults in Configuration class
42
+ #
43
+ # Misc configuration point
44
+ #
45
+ initializer "panda_core.configuration" do
46
+ # Intentionally quiet — used as a stable anchor point
55
47
  end
56
48
 
57
- # Static asset middleware for serving public files and JavaScript modules
58
- # Must run before Propshaft to intercept /panda/* requests, but we can't
59
- # guarantee Propshaft is in the host application, so just insert it
60
- # high up in the middleware stack
61
- initializer "panda.core.static_assets" do |app|
62
- # Serve public assets (CSS, images, etc.)
63
- app.config.middleware.insert_before ActionDispatch::Static, Rack::Static,
49
+ #
50
+ # Static asset handling for:
51
+ # /panda-core-assets
52
+ #
53
+ initializer "panda_core.static_assets" do |app|
54
+ Panda::Core::Middleware.use(
55
+ app,
56
+ Rack::Static,
64
57
  urls: ["/panda-core-assets"],
65
58
  root: Panda::Core::Engine.root.join("public"),
66
59
  header_rules: [
67
- # Disable caching in development for instant CSS updates
68
- [:all, {"Cache-Control" => Rails.env.development? ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000"}]
60
+ [
61
+ :all,
62
+ {
63
+ "Cache-Control" =>
64
+ Rails.env.development? ?
65
+ "no-cache, no-store, must-revalidate" :
66
+ "public, max-age=31536000"
67
+ }
68
+ ]
69
69
  ]
70
+ )
70
71
 
71
- # Use ModuleRegistry's custom middleware to serve JavaScript from all registered modules
72
- # This middleware checks all modules and serves from the first matching location
73
- app.config.middleware.insert_before ActionDispatch::Static, Panda::Core::ModuleRegistry::JavaScriptMiddleware
72
+ Panda::Core::Middleware.use(
73
+ app,
74
+ Panda::Core::ModuleRegistry::JavaScriptMiddleware
75
+ )
74
76
  end
75
77
  end
76
78
  end
77
79
  end
78
80
 
79
- # Register Core module with ModuleRegistry for JavaScript serving
81
+ #
82
+ # Register engine with ModuleRegistry
83
+ #
80
84
  Panda::Core::ModuleRegistry.register(
81
85
  gem_name: "panda-core",
82
86
  engine: "Panda::Core::Engine",
@@ -85,6 +89,7 @@ Panda::Core::ModuleRegistry.register(
85
89
  components: "app/components/panda/core/**/*.rb",
86
90
  helpers: "app/helpers/panda/core/**/*.rb",
87
91
  views: "app/views/panda/core/**/*.erb",
92
+ layouts: "app/views/layouts/panda/core/**/*.erb",
88
93
  javascripts: "app/assets/javascript/panda/core/**/*.js"
89
94
  }
90
95
  )
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+
5
+ module Rails
6
+ module Configuration
7
+ # Add a tiny compatibility layer so the proxy can be inspected and reset in tests.
8
+ class MiddlewareStackProxy
9
+ include Enumerable
10
+
11
+ def clear
12
+ operations.clear
13
+ delete_operations.clear
14
+ self
15
+ end
16
+
17
+ def each(&block)
18
+ to_stack.each { |mw| block.call(wrap_middleware(mw)) }
19
+ end
20
+
21
+ def to_a
22
+ map(&:itself)
23
+ end
24
+
25
+ def [](index)
26
+ to_a[index]
27
+ end
28
+
29
+ def first
30
+ to_a.first
31
+ end
32
+
33
+ def last
34
+ to_a.last
35
+ end
36
+
37
+ def size
38
+ to_a.size
39
+ end
40
+
41
+ private
42
+
43
+ def to_stack
44
+ ActionDispatch::MiddlewareStack.new.tap do |stack|
45
+ merge_into(stack)
46
+ end
47
+ end
48
+
49
+ def wrap_middleware(middleware)
50
+ args, kwargs = split_args_and_kwargs(middleware.args)
51
+ MiddlewareEntry.new(middleware.klass, args, kwargs, middleware.block)
52
+ end
53
+
54
+ def split_args_and_kwargs(args)
55
+ return [args, {}] unless args.last.is_a?(Hash)
56
+
57
+ [args[0...-1], args.last]
58
+ end
59
+
60
+ MiddlewareEntry = Struct.new(:klass, :args, :kwargs, :block)
61
+ end
62
+ end
63
+ end
64
+
65
+ module Panda
66
+ module Core
67
+ module Middleware
68
+ EXECUTOR = "ActionDispatch::Executor"
69
+
70
+ def self.use(app, klass, *args, **kwargs, &block)
71
+ app.config.middleware.use(klass, *args, **kwargs, &block)
72
+ end
73
+
74
+ def self.insert_before(app, priority_targets, klass, *args, **kwargs, &block)
75
+ stack = build_stack(app)
76
+ target = resolve_target(stack, priority_targets, :insert_before)
77
+ insertion_point = target || fallback_before(stack)
78
+ app.config.middleware.insert_before(insertion_point, klass, *args, **kwargs, &block)
79
+ end
80
+
81
+ def self.insert_after(app, priority_targets, klass, *args, **kwargs, &block)
82
+ stack = build_stack(app)
83
+ target = resolve_target(stack, priority_targets, :insert_after)
84
+ insertion_point = target || fallback_after(stack)
85
+ app.config.middleware.insert_after(insertion_point, klass, *args, **kwargs, &block)
86
+ end
87
+
88
+ #
89
+ # Helpers
90
+ #
91
+ def self.resolve_target(app_or_stack, priority_targets, _operation)
92
+ stack = normalize_stack(app_or_stack)
93
+ priority_targets.find { |candidate| middleware_exists?(stack, candidate) }
94
+ end
95
+ private_class_method :resolve_target
96
+
97
+ def self.middleware_exists?(app_or_stack, candidate)
98
+ stack = normalize_stack(app_or_stack)
99
+ stack.any? { |mw| middleware_matches?(mw.klass, candidate) }
100
+ end
101
+ private_class_method :middleware_exists?
102
+
103
+ def self.middleware_matches?(klass, candidate)
104
+ klass == candidate || klass.name == candidate.to_s
105
+ end
106
+ private_class_method :middleware_matches?
107
+
108
+ def self.fallback_before(stack)
109
+ executor_index = index_for(stack, EXECUTOR)
110
+ executor_index || 0
111
+ end
112
+ private_class_method :fallback_before
113
+
114
+ def self.fallback_after(stack)
115
+ executor_index = index_for(stack, EXECUTOR)
116
+ return executor_index + 1 if executor_index
117
+
118
+ stack_size = stack.size
119
+ stack_size.zero? ? 0 : stack_size - 1
120
+ end
121
+ private_class_method :fallback_after
122
+
123
+ def self.index_for(stack, target)
124
+ stack.each_with_index do |middleware, idx|
125
+ return idx if middleware_matches?(middleware.klass, target)
126
+ end
127
+ nil
128
+ end
129
+ private_class_method :index_for
130
+
131
+ def self.build_stack(app)
132
+ app.config.middleware.to_a
133
+ end
134
+ private_class_method :build_stack
135
+
136
+ def self.normalize_stack(app_or_stack)
137
+ if app_or_stack.respond_to?(:config) && app_or_stack.config.respond_to?(:middleware)
138
+ build_stack(app_or_stack)
139
+ else
140
+ app_or_stack
141
+ end
142
+ end
143
+ private_class_method :normalize_stack
144
+ end
145
+ end
146
+ end
@@ -343,6 +343,10 @@ module Panda
343
343
  private
344
344
 
345
345
  def find_javascript_file(relative_path)
346
+ # Build list of paths to try - include .js extension fallback
347
+ paths_to_try = [relative_path]
348
+ paths_to_try << "#{relative_path}.js" unless relative_path.end_with?(".js")
349
+
346
350
  # Check each registered module's JavaScript directory
347
351
  ModuleRegistry.modules.each do |gem_name, info|
348
352
  next unless ModuleRegistry.send(:engine_available?, info[:engine])
@@ -350,24 +354,28 @@ module Panda
350
354
  root = ModuleRegistry.send(:engine_root, info[:engine])
351
355
  next unless root
352
356
 
353
- # Check in app/javascript/panda/ (primary location)
354
- candidate = root.join("app/javascript/panda", relative_path)
355
- return candidate.to_s if candidate.exist? && candidate.file?
357
+ paths_to_try.each do |path|
358
+ # Check in app/javascript/panda/ (primary location)
359
+ candidate = root.join("app/javascript/panda", path)
360
+ return candidate.to_s if candidate.exist? && candidate.file?
356
361
 
357
- # Fallback to public/panda/ (for CI environments where assets are copied)
358
- public_candidate = root.join("public/panda", relative_path)
359
- return public_candidate.to_s if public_candidate.exist? && public_candidate.file?
362
+ # Fallback to public/panda/ (for CI environments where assets are copied)
363
+ public_candidate = root.join("public/panda", path)
364
+ return public_candidate.to_s if public_candidate.exist? && public_candidate.file?
365
+ end
360
366
  end
361
367
 
362
368
  # Also check Rails.root if available (for dummy apps in CI)
363
369
  if defined?(Rails.root)
364
- # Check app/javascript/panda/ in Rails.root
365
- rails_candidate = Rails.root.join("app/javascript/panda", relative_path)
366
- return rails_candidate.to_s if rails_candidate.exist? && rails_candidate.file?
367
-
368
- # Fallback to public/panda/ in Rails.root
369
- rails_public_candidate = Rails.root.join("public/panda", relative_path)
370
- return rails_public_candidate.to_s if rails_public_candidate.exist? && rails_public_candidate.file?
370
+ paths_to_try.each do |path|
371
+ # Check app/javascript/panda/ in Rails.root
372
+ rails_candidate = Rails.root.join("app/javascript/panda", path)
373
+ return rails_candidate.to_s if rails_candidate.exist? && rails_candidate.file?
374
+
375
+ # Fallback to public/panda/ in Rails.root
376
+ rails_public_candidate = Rails.root.join("public/panda", path)
377
+ return rails_public_candidate.to_s if rails_public_candidate.exist? && rails_public_candidate.file?
378
+ end
371
379
  end
372
380
 
373
381
  nil
@@ -104,11 +104,6 @@ RSpec.configure do |config|
104
104
  ActionMailer::Base.logger = nil if defined?(ActionMailer)
105
105
  end
106
106
 
107
- # Suppress Rails command output during generator tests
108
- config.before(:each, type: :generator) do
109
- allow(Rails::Command).to receive(:invoke).and_return(true)
110
- end
111
-
112
107
  # Force all examples to run
113
108
  config.filter_run_including({})
114
109
  config.run_all_when_everything_filtered = true
@@ -169,14 +164,28 @@ RSpec.configure do |config|
169
164
  OmniAuth.config.test_mode = true if defined?(OmniAuth)
170
165
 
171
166
  # DatabaseCleaner configuration
167
+ config.around(:each) do |example|
168
+ DatabaseCleaner.strategy = (example.metadata[:type] == :system) ? :truncation : :transaction
169
+ DatabaseCleaner.cleaning do
170
+ example.run
171
+ end
172
+ end
173
+
174
+ config.before(:each, type: :system) do
175
+ driven_by :panda_cuprite
176
+ end
177
+
178
+ config.use_transactional_fixtures = false
179
+
172
180
  config.before(:suite) do
173
181
  # Allow DATABASE_URL in CI environment
174
- if ENV["DATABASE_URL"]
182
+ if ENV["DATABASE_URL"] &&
183
+ ENV["SKIP_DB_CLEAN_WITH_DATABASE_URL"].nil? &&
184
+ ENV["ACT"] != "true"
175
185
  DatabaseCleaner.allow_remote_database_url = true
186
+ DatabaseCleaner.clean_with(:truncation)
176
187
  end
177
188
 
178
- DatabaseCleaner.clean_with :truncation
179
-
180
189
  # Hook for gems to add custom suite setup
181
190
  # Gems can define Panda::Testing.before_suite_hook and it will be called here
182
191
  if defined?(Panda::Testing) && Panda::Testing.respond_to?(:before_suite_hook)
@@ -4,18 +4,18 @@ module Panda
4
4
  module Core
5
5
  module AuthenticationHelpers
6
6
  # Create test users with fixed IDs for consistent fixture references
7
- def create_admin_user
7
+ def create_admin_user(attributes = {})
8
8
  Panda::Core::User.find_or_create_by!(id: "8f481fcb-d9c8-55d7-ba17-5ea5d9ed8b7a") do |user|
9
- user.email = "admin@test.example.com"
10
- user.name = "Admin User"
9
+ user.email = attributes[:email] || "admin@test.example.com"
10
+ user.name = attributes[:name] || "Admin User"
11
11
  user.admin = true
12
12
  end
13
13
  end
14
14
 
15
- def create_regular_user
15
+ def create_regular_user(attributes = {})
16
16
  Panda::Core::User.find_or_create_by!(id: "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") do |user|
17
- user.email = "user@test.example.com"
18
- user.name = "Regular User"
17
+ user.email = attributes[:email] || "user@test.example.com"
18
+ user.name = attributes[:name] || "Regular User"
19
19
  user.admin = false
20
20
  end
21
21
  end
@@ -22,12 +22,7 @@ module Panda
22
22
  user.email = attributes[:email] || "admin@example.com"
23
23
  user.name = attributes[:name] || "Admin User"
24
24
  user.image_url = attributes[:image_url] || default_image_url
25
- # Use is_admin for the actual column, but support both for compatibility
26
- if user.respond_to?(:is_admin=)
27
- user.is_admin = attributes.fetch(:admin, true)
28
- elsif user.respond_to?(:admin=)
29
- user.admin = attributes.fetch(:admin, true)
30
- end
25
+ user.admin = attributes[:admin] || true
31
26
  # Only set OAuth fields if they exist on the model
32
27
  user.uid = attributes[:uid] || "admin_oauth_uid_123" if user.respond_to?(:uid=)
33
28
  user.provider = attributes[:provider] || "google_oauth2" if user.respond_to?(:provider=)
@@ -42,12 +37,7 @@ module Panda
42
37
  user.email = attributes[:email] || "user@example.com"
43
38
  user.name = attributes[:name] || "Regular User"
44
39
  user.image_url = attributes[:image_url] || default_image_url(dark: true)
45
- # Use is_admin for the actual column, but support both for compatibility
46
- if user.respond_to?(:is_admin=)
47
- user.is_admin = attributes.fetch(:admin, false)
48
- elsif user.respond_to?(:admin=)
49
- user.admin = attributes.fetch(:admin, false)
50
- end
40
+ user.admin = attributes[:admin] || false
51
41
  # Only set OAuth fields if they exist on the model
52
42
  user.uid = attributes[:uid] || "user_oauth_uid_456" if user.respond_to?(:uid=)
53
43
  user.provider = attributes[:provider] || "google_oauth2" if user.respond_to?(:provider=)
@@ -9,8 +9,7 @@ module BrowserConsoleLogger
9
9
 
10
10
  if respond_to?(:page) && page.driver.is_a?(Capybara::Cuprite::Driver)
11
11
  begin
12
- # Access the console logger via the CupriteSetup module
13
- console_logger = Panda::Core::Testing::CupriteSetup.console_logger
12
+ console_logger = Panda::Core::Testing::CupriteSetup.console_logger if defined?(Panda::Core::Testing::CupriteSetup)
14
13
 
15
14
  unless console_logger
16
15
  puts "\n⚠️ Console logger not available"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Testing
6
+ module Support
7
+ module System
8
+ module ChromePath
9
+ def self.resolve
10
+ @resolved ||= begin
11
+ candidates = [
12
+ # Linux (Debian/Ubuntu + your CI image)
13
+ "/usr/bin/chromium",
14
+ "/usr/bin/chromium-browser",
15
+ "/usr/bin/google-chrome",
16
+ "/opt/google/chrome/google-chrome",
17
+ "/opt/google/chrome/chrome",
18
+
19
+ # macOS Homebrew paths (Chromium)
20
+ "/opt/homebrew/bin/chromium",
21
+ "/usr/local/bin/chromium",
22
+
23
+ # macOS Google Chrome
24
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
25
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
26
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
27
+ ]
28
+
29
+ candidates.find { |path| File.executable?(path) } ||
30
+ raise("Could not find a Chrome/Chromium binary on this system")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  # Helper methods for Cuprite-based system tests
4
6
  #
5
7
  # This module provides utility methods for working with Cuprite in system tests:
@@ -16,6 +18,83 @@ module Panda
16
18
  module Core
17
19
  module Testing
18
20
  module CupriteHelpers
21
+ # Resolve the configured Capybara artifacts directory
22
+ def capybara_artifacts_dir
23
+ Pathname.new(Capybara.save_path || Rails.root.join("tmp/capybara"))
24
+ end
25
+
26
+ # Save a PNG screenshot for the current page.
27
+ #
28
+ def save_screenshot!(name = nil)
29
+ name ||= example.metadata[:full_description].parameterize
30
+ path = capybara_artifacts_dir.join("#{name}.png")
31
+
32
+ FileUtils.mkdir_p(File.dirname(path))
33
+ page.save_screenshot(path, full: true) # rubocop:disable Lint/Debugger
34
+ puts "📸 Saved screenshot: #{path}"
35
+
36
+ path
37
+ end
38
+
39
+ #
40
+ # Record a small MP4 video of the test — uses Cuprite's Chrome DevTools API
41
+ #
42
+ def record_video!(name = nil, seconds: 3)
43
+ name ||= example.metadata[:full_description].parameterize
44
+ path = capybara_artifacts_dir.join("#{name}.mp4")
45
+
46
+ FileUtils.mkdir_p(File.dirname(path))
47
+
48
+ session = page.driver.browser
49
+ client = session.client
50
+
51
+ # Enable screencast
52
+ client.command("Page.startScreencast", format: "png", quality: 80, maxWidth: 1280, maxHeight: 800)
53
+
54
+ frames = []
55
+
56
+ start = Time.now
57
+ while Time.now - start < seconds
58
+ message = client.listen
59
+ if message["method"] == "Page.screencastFrame"
60
+ frames << message["params"]["data"]
61
+ client.command("Page.screencastFrameAck", sessionId: message["params"]["sessionId"])
62
+ end
63
+ end
64
+
65
+ # Stop
66
+ client.command("Page.stopScreencast")
67
+
68
+ # Convert frames to MP4 using ffmpeg
69
+ Dir.mktmpdir do |dir|
70
+ png_dir = File.join(dir, "frames")
71
+ FileUtils.mkdir_p(png_dir)
72
+
73
+ frames.each_with_index do |data, i|
74
+ File.binwrite(File.join(png_dir, "frame-%05d.png" % i), Base64.decode64(data))
75
+ end
76
+
77
+ system <<~CMD
78
+ ffmpeg -y -framerate 8 -pattern_type glob -i '#{png_dir}/*.png' -c:v libx264 -pix_fmt yuv420p '#{path}'
79
+ CMD
80
+ end
81
+
82
+ puts "🎥 Saved video: #{path}"
83
+ path
84
+ rescue => e
85
+ puts "⚠️ Failed to record video: #{e.message}"
86
+ nil
87
+ end
88
+
89
+ def save_html!(name = nil)
90
+ name ||= example.metadata[:full_description].parameterize
91
+ path = capybara_artifacts_dir.join("#{name}.html")
92
+ FileUtils.mkdir_p(File.dirname(path))
93
+ File.write(path, page.html)
94
+ puts "📝 Saved HTML snapshot: #{path}"
95
+ path
96
+ end
97
+
19
98
  # Ensure page is loaded and stable before interacting
20
99
  def ensure_page_loaded
21
100
  # Check if we're on about:blank and need to reload