diff --git a/Gemfile.lock b/Gemfile.lock index 3d0b00e..cd910ca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,7 +81,7 @@ GEM base64 (0.3.0) bigdecimal (4.1.2) bindex (0.8.1) - bootsnap (1.23.0) + bootsnap (1.24.4) msgpack (~> 1.2) brakeman (8.0.4) racc @@ -117,7 +117,7 @@ GEM erb (6.0.4) erubi (1.13.1) erubis (2.7.0) - factory_bot (6.5.6) + factory_bot (6.6.0) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) @@ -142,15 +142,12 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.14.1) + jbuilder (2.15.0) actionview (>= 7.0.0) activesupport (>= 7.0.0) jsbundling-rails (1.3.1) railties (>= 6.0.0) json (2.19.5) - json-schema (6.1.0) - addressable (~> 2.8) - bigdecimal (>= 3.1, < 5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -165,8 +162,6 @@ GEM net-smtp marcel (1.1.0) matrix (0.4.3) - mcp (0.7.1) - json-schema (>= 4.1) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) @@ -175,13 +170,14 @@ GEM minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) - mongo (2.23.0) + mongo (2.24.0) base64 bson (>= 4.14.1, < 6.0.0) - mongoid (9.0.10) + mongoid (9.1.0) activemodel (>= 5.1, < 8.2, != 7.0.0) concurrent-ruby (>= 1.0.5, < 2.0) mongo (>= 2.18.0, < 3.0.0) + ostruct msgpack (1.8.0) multipart-post (2.4.1) net-http (0.9.1) @@ -205,15 +201,15 @@ GEM nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) ostruct (0.6.3) - parallel (1.27.0) - parser (3.3.10.2) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) racc pg (1.6.3-aarch64-linux) pg (1.6.3-arm64-darwin) pg (1.6.3-x86_64-darwin) pg (1.6.3-x86_64-linux) - playwright-ruby-client (1.59.0) + playwright-ruby-client (1.60.0) base64 concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) @@ -221,7 +217,7 @@ GEM prettyprint prettyprint (0.2.0) prism (1.9.0) - propshaft (1.3.1) + propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack @@ -229,7 +225,7 @@ GEM date stringio public_suffix (7.0.5) - puma (7.2.0) + puma (8.0.1) nio4r (~> 2.0) racc (1.8.1) rack (3.2.6) @@ -265,12 +261,13 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails_best_practices (1.23.3) + rails_best_practices (1.23.4) activesupport code_analyzer (~> 0.5.5) erubis i18n json + ostruct require_all (~> 3.0) ruby-progressbar railties (8.1.3) @@ -290,9 +287,9 @@ GEM tsort redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.26.4) + redis-client (0.29.0) connection_pool - regexp_parser (2.11.3) + regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) require_all (3.0.0) @@ -304,35 +301,34 @@ GEM rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.3) + rspec-rails (8.0.4) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) - rubocop (1.85.0) + rubocop (1.86.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - mcp (~> 0.6) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.3) + rubocop-rails (2.35.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -345,7 +341,7 @@ GEM ruby-progressbar (1.13.0) securerandom (0.4.1) sexp_processor (4.17.5) - spring (4.4.2) + spring (4.5.0) stringio (3.2.0) thor (1.5.0) timeout (0.6.1) diff --git a/README.md b/README.md index f8dbc83..78340a7 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,14 @@ $ ./bin/dev `bin/dev` runs Rails plus pnpm JS and Tailwind CSS watchers, so Hotwire and stylesheet assets stay current while you work. The same scripts are available directly as `pnpm build`, `pnpm build:js:watch`, and `pnpm build:css:watch`. +The Playwright visual smoke spec uses the browser version expected by `playwright-ruby-client`; CI installs it automatically. +For local visual snapshots: + +```console +$ PLAYWRIGHT_VERSION=$(mise exec -- ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION') +$ mise exec -- npx "playwright@$PLAYWRIGHT_VERSION" install chromium +$ VISUAL_SNAPSHOTS=1 mise exec -- bundle exec rspec spec/system/all_pages_visual_smoke_spec.rb +``` ![Application is available](./references/application-up-in-browser.png) diff --git a/app/views/file_metadata/all_metadata_show.html.erb b/app/views/file_metadata/all_metadata_show.html.erb index 46e8091..679dcca 100644 --- a/app/views/file_metadata/all_metadata_show.html.erb +++ b/app/views/file_metadata/all_metadata_show.html.erb @@ -5,7 +5,7 @@ <%= @metadata %> -
+
<%= form_tag(metadata_show_file_metadata_path, method: :get, class: 'uc-form-panel') do %> <%= hidden_field_tag :uuid, @uuid %>
@@ -17,7 +17,7 @@ <% end %>
-
+
<%= form_tag(metadata_update_file_metadata_path, method: :patch, class: 'uc-form-panel') do %> <%= hidden_field_tag :uuid, @uuid %>
@@ -32,7 +32,7 @@ <% end %>
-
+
<%= form_tag(metadata_update_file_metadata_path, method: :patch, class: 'uc-form-panel') do %> <%= hidden_field_tag :uuid, @uuid %>
@@ -47,7 +47,7 @@ <% end %>
-
+
<%= form_tag(metadata_delete_file_metadata_path, method: :delete, class: 'uc-form-panel') do %> <%= hidden_field_tag :uuid, @uuid %>
diff --git a/spec/support/system_driver.rb b/spec/support/system_driver.rb index 2dfbcec..5945dd9 100644 --- a/spec/support/system_driver.rb +++ b/spec/support/system_driver.rb @@ -1,7 +1,27 @@ # frozen_string_literal: true +require "capybara-playwright-driver" +require "playwright" + +PLAYWRIGHT_CLI_EXECUTABLE_PATH = ENV.fetch( + "PLAYWRIGHT_CLI_EXECUTABLE_PATH", + "npx playwright@#{Playwright::COMPATIBLE_PLAYWRIGHT_VERSION}" +) + +Capybara.register_driver(:playwright) do |app| + Capybara::Playwright::Driver.new( + app, + browser_type: :chromium, + headless: ENV.fetch("PLAYWRIGHT_HEADLESS", "true") != "false", + playwright_cli_executable_path: PLAYWRIGHT_CLI_EXECUTABLE_PATH + ) +end + +Capybara.save_path = Rails.root.join("tmp/capybara").to_s +Capybara.server = :puma, { Silent: true } + RSpec.configure do |config| - config.before(:each, type: :system) do - driven_by :rack_test + config.before(:each, type: :system) do |example| + driven_by(example.metadata[:playwright] ? :playwright : :rack_test) end end diff --git a/spec/system/all_pages_visual_smoke_spec.rb b/spec/system/all_pages_visual_smoke_spec.rb new file mode 100644 index 0000000..1980408 --- /dev/null +++ b/spec/system/all_pages_visual_smoke_spec.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "All primary pages", type: :system, playwright: true do + # Playwright drives the app through Capybara's Puma server thread, so records + # need to be committed before the browser requests each page and cleaned here. + self.use_transactional_tests = false + + VIEWPORTS = { + desktop: { width: 1365, height: 900 }, + mobile: { width: 390, height: 844 } + }.freeze + + let(:uuid) { "3ae6a420-9de3-4088-9fad-301de9932251" } + let(:group_id) { "#{uuid}~1" } + let(:post_record) { create(:post) } + let(:comment_record) { create(:comment) } + let(:active_storage_post) { create(:active_storage_post) } + + let(:files_accessor) { double("UploadcareFilesAccessor") } + let(:groups_accessor) { double("UploadcareGroupsAccessor") } + let(:project_accessor) { double("UploadcareProjectAccessor") } + let(:webhooks_accessor) { double("UploadcareWebhooksAccessor") } + let(:metadata_accessor) { double("UploadcareFileMetadataAccessor") } + let(:documents_accessor) { double("UploadcareDocumentConversionsAccessor") } + let(:videos_accessor) { double("UploadcareVideoConversionsAccessor") } + let(:conversions_accessor) { double("UploadcareConversionsAccessor", documents: documents_accessor, videos: videos_accessor) } + let(:addons_accessor) { double("UploadcareAddonsAccessor") } + let(:client) { Uploadcare::Client.new(public_key: "demopublickey", secret_key: "demoprivatekey") } + + before do + Post.delete_all + Comment.delete_all + ActiveStoragePost.delete_all + + post_record + comment_record + active_storage_post + + stub_uploadcare_client(client) + stub_uploadcare_client_accessors + stub_uploadcare_resources + stub_model_uploadcare_attachments + end + + after do + Post.delete_all + Comment.delete_all + ActiveStoragePost.delete_all + end + + it "renders every primary page at desktop and mobile widths with the current asset pipeline" do + VIEWPORTS.each do |viewport, size| + resize_to(size) + + primary_pages.each do |label, path, expected_text| + visit path + + aggregate_failures("#{viewport} #{label}") do + expect(page).to have_text(expected_text) + expect(page).to have_css("main") + expect(page).to have_css("link[href*='tailwind']", visible: :all) + + expect(layout_metrics.fetch("main_top")).to be < layout_metrics.fetch("aside_top") if viewport == :mobile + end + + save_visual_snapshot(viewport, label) if ENV["VISUAL_SNAPSHOTS"] == "1" + end + end + end + + private + + def primary_pages + [ + [ "root", root_path, "Project Info" ], + [ "project", project_path, "Project Info" ], + [ "examples", examples_path, "Examples" ], + [ "uploader helper examples", uploader_fields_examples_path, "Uploader Helper Examples" ], + [ "files index", files_path, "List files" ], + [ "file show", file_path(uuid), "File papaya.jpg info" ], + [ "batch store files", new_store_file_batch_path, "Batch store file" ], + [ "batch delete files", new_delete_file_batch_path, "Batch delete file" ], + [ "local upload", upload_new_local_file_path, "Upload a local file" ], + [ "url upload", upload_new_file_from_url_path, "Upload a file from URL" ], + [ "file groups index", file_groups_path, "List files" ], + [ "new file group", new_file_group_path, "Create group of files" ], + [ "file group show", file_group_path(group_id), "Group info" ], + [ "document conversion info form", new_document_conversion_information_path, "Conversion formats info" ], + [ "document conversion info result", document_conversion_information_path(file: uuid), "Conversion formats info" ], + [ "document conversion form", new_document_conversion_path, "Document conversion" ], + [ "document conversion status", document_conversion_path(token: "doc-token", original_source: "#{uuid}/document/"), "Document conversion result" ], + [ "document conversion problem", document_conversion_path(problem_source: "#{uuid}/document/", problem_reason: "Conversion failed"), "Document conversion result" ], + [ "video conversion form", new_video_conversion_path, "Video conversion" ], + [ "video conversion status", video_conversion_path(token: "video-token", original_source: "#{uuid}/video/", thumbnails_group_uuid: group_id), "Video conversion result" ], + [ "video conversion problem", video_conversion_path(problem_source: "#{uuid}/video/", problem_reason: "Conversion failed"), "Video conversion result" ], + [ "webhooks index", webhooks_path, "Webhooks list" ], + [ "new webhook", new_webhook_path, "Create a new webhook" ], + [ "webhook show", webhook_path(816_965), "Webhook ID 816965 info" ], + [ "webhook edit", edit_webhook_path(816_965), "Edit webhook" ], + [ "posts index", posts_path, "Posts" ], + [ "new post", new_post_path, "Create a new post" ], + [ "post show", post_path(post_record), "Post" ], + [ "post edit", edit_post_path(post_record), "Edit post" ], + [ "active storage posts index", active_storage_posts_path, "Active Storage posts" ], + [ "new active storage post", new_active_storage_post_path, "Create a new Active Storage post" ], + [ "active storage post show", active_storage_post_path(active_storage_post), "Active Storage post" ], + [ "active storage post edit", edit_active_storage_post_path(active_storage_post), "Edit Active Storage post" ], + [ "comments index", comments_path, "Comments" ], + [ "new comment", new_comment_path, "Create a new comment" ], + [ "comment show", comment_path(comment_record), "Comment" ], + [ "comment edit", edit_comment_path(comment_record), "Edit comment" ], + [ "file metadata index", file_metadata_path, "Select file" ], + [ "all file metadata", all_metadata_show_file_metadata_path(uuid: uuid), "uuid: #{uuid}" ], + [ "single file metadata", metadata_show_file_metadata_path(uuid: uuid, key: "kind"), "key: kind" ], + [ "virus scan index", virus_scan_index_path(uuid: "virus-request"), "virus-request" ], + [ "new virus scan", new_virus_scan_path, "Check file on virus" ], + [ "virus scan status", show_status_virus_scan_index_path(status: "done"), "done" ], + [ "rekognition labels index", rekognition_labels_path(uuid: "labels-request"), "labels-request" ], + [ "new rekognition labels", new_rekognition_label_path, "Rekognition file labels" ], + [ "rekognition labels status", show_status_rekognition_labels_path(status: "done"), "done" ], + [ "remove bg index", remove_bg_index_path(uuid: "remove-bg-request"), "remove-bg-request" ], + [ "new remove bg", new_remove_bg_path, "Remove file background" ], + [ "remove bg status", show_status_remove_bg_index_path(status: "done"), "done" ], + [ "rekognition moderation labels index", rekognition_moderation_labels_path(uuid: "moderation-request"), "moderation-request" ], + [ "new rekognition moderation labels", new_rekognition_moderation_label_path, "Rekognition moderation file labels" ], + [ "rekognition moderation labels status", show_status_rekognition_moderation_labels_path(status: "done"), "done" ] + ] + end + + def stub_uploadcare_resources + allow(files_accessor).to receive(:list).and_return(uploadcare_paginated(uploadcare_file)) + allow(files_accessor).to receive(:find).with(uuid: uuid).and_return(uploadcare_file) + allow(groups_accessor).to receive(:list).and_return(uploadcare_paginated(file_group)) + allow(groups_accessor).to receive(:find).with(group_id: group_id).and_return(file_group) + allow(project_accessor).to receive(:current).and_return(project) + allow(webhooks_accessor).to receive(:list).and_return([ webhook ]) + allow(metadata_accessor).to receive(:index).with(uuid: uuid).and_return("kind" => "fruit") + allow(metadata_accessor).to receive(:show).with(uuid: uuid, key: "kind").and_return("fruit") + allow(documents_accessor).to receive(:info).with(uuid: uuid).and_return(document_info) + allow(documents_accessor).to receive(:status).with(token: "doc-token").and_return(conversion_status) + allow(videos_accessor).to receive(:status).with(token: "video-token").and_return(conversion_status) + end + + def stub_uploadcare_client_accessors + allow(client).to receive(:files).and_return(files_accessor) + allow(client).to receive(:groups).and_return(groups_accessor) + allow(client).to receive(:project).and_return(project_accessor) + allow(client).to receive(:webhooks).and_return(webhooks_accessor) + allow(client).to receive(:file_metadata).and_return(metadata_accessor) + allow(client).to receive(:conversions).and_return(conversions_accessor) + allow(client).to receive(:addons).and_return(addons_accessor) + end + + def stub_model_uploadcare_attachments + # These examples cover rendered pages, not Uploadcare attachment loading. + # Stubbing at the controller boundary keeps the smoke test network-free. + allow_any_instance_of(PostsController).to receive(:load_group_files).and_return([ uploadcare_file ]) + allow_any_instance_of(CommentsController).to receive(:load_group_files).and_return([ uploadcare_file ]) + allow_any_instance_of(Uploadcare::Rails::AttachedFile).to receive(:load).and_return(attached_uploadcare_file) + end + + def project + Uploadcare::Project.new( + { + "collaborators" => [ { "name" => "Mike", "email" => "mike@example.com" } ], + "name" => "Example project", + "pub_key" => "demopublickey", + "autostore_enabled" => true + }, + Uploadcare.client + ) + end + + def uploadcare_file + Uploadcare::File.new( + { + "datetime_removed" => nil, + "datetime_uploaded" => "2026-05-18T12:30:00Z", + "content_info" => { + "image" => { + "color_mode" => "RGB", + "orientation" => nil, + "format" => "JPEG", + "sequence" => false, + "height" => 500, + "width" => 500, + "geo_location" => nil, + "dpi" => [ 144, 144 ] + } + }, + "is_image" => true, + "is_ready" => true, + "mime_type" => "image/jpeg", + "original_file_url" => "https://ucarecdn.com/#{uuid}/papaya.jpg", + "original_filename" => "papaya.jpg", + "size" => 642, + "url" => "https://api.uploadcare.com/files/#{uuid}/", + "uuid" => uuid, + "variations" => nil, + "metadata" => { "kind" => "fruit" } + }, + Uploadcare.client + ) + end + + def attached_uploadcare_file + Uploadcare::Rails::AttachedFile.new( + { + "uuid" => uuid, + "cdn_url" => "https://ucarecdn.com/#{uuid}/", + "original_filename" => "papaya.jpg", + "mime_type" => "image/jpeg" + } + ) + end + + def file_group + Uploadcare::Group.new( + { + "id" => group_id, + "datetime_created" => "2026-05-18T12:30:00Z", + "files_count" => 1, + "cdn_url" => "https://ucarecdn.com/#{group_id}/", + "url" => "https://api.uploadcare.com/groups/#{group_id}/", + "files" => [ + { + "uuid" => uuid, + "original_filename" => "papaya.jpg", + "mime_type" => "image/jpeg" + } + ] + }, + Uploadcare.client + ) + end + + def webhook + Uploadcare::Webhook.new( + { + "id" => 816_965, + "created" => "2026-05-18T12:30:00Z", + "updated" => "2026-05-18T12:35:00Z", + "event" => "file.uploaded", + "target_url" => "https://example.com/uploadcare", + "project" => 123_681, + "is_active" => true + }, + Uploadcare.client + ) + end + + def document_info + double( + "DocumentConversionInfo", + format: { + "name" => "pdf", + "conversion_formats" => [ { "name" => "docx" } ], + "converted_groups" => { "docx" => group_id } + } + ) + end + + def conversion_status + double("ConversionStatus", status: "finished", error: nil, result: [ { "uuid" => uuid } ]) + end + + def resize_to(size) + page.driver.with_playwright_page do |playwright_page| + playwright_page.viewport_size = size + end + end + + def layout_metrics + page.evaluate_script(<<~JS) + (() => { + const main = document.querySelector("main").getBoundingClientRect(); + const aside = document.querySelector("aside").getBoundingClientRect(); + + return { + main_top: main.top, + aside_top: aside.top + }; + })() + JS + end + + def save_visual_snapshot(viewport, label) + screenshot_dir = Rails.root.join("tmp/playwright-pages") + FileUtils.mkdir_p(screenshot_dir) + page.save_screenshot(screenshot_dir.join("#{viewport}-#{label.parameterize}.png")) + end +end