active_storage-async_variants 0.3.1 → 0.5.0

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +2 -22
  4. data/Rakefile +9 -1
  5. data/app/assets/javascripts/retry.js +11 -0
  6. data/app/assets/stylesheets/retry.css +98 -0
  7. data/app/controllers/active_storage/async_variants/states_controller.rb +62 -0
  8. data/app/views/active_storage/async_variants/states/_failed.html.erb +43 -0
  9. data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
  10. data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
  11. data/app/views/active_storage/async_variants/states/_processing.html.erb +9 -0
  12. data/app/views/active_storage/async_variants/states/show.html.erb +10 -0
  13. data/config/routes.rb +9 -0
  14. data/features/retry_flow.feature +20 -0
  15. data/features/retry_visibility.feature +23 -0
  16. data/features/state_rendering.feature +22 -0
  17. data/features/step_definitions/dummy_steps.rb +116 -0
  18. data/features/support/env.rb +28 -0
  19. data/gemfiles/rails_7.2.gemfile +4 -0
  20. data/gemfiles/rails_8.0.gemfile +4 -0
  21. data/gemfiles/rails_8.1.gemfile +4 -0
  22. data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +38 -56
  23. data/lib/active_storage/async_variants/helper.rb +59 -0
  24. data/lib/active_storage/async_variants/preview_extension.rb +35 -32
  25. data/lib/active_storage/async_variants/variant_with_record_extension.rb +40 -30
  26. data/lib/active_storage/async_variants/version.rb +1 -1
  27. data/lib/active_storage/async_variants.rb +26 -15
  28. metadata +44 -4
  29. data/app/assets/javascripts/active_storage_async_variants.js +0 -169
  30. data/lib/active_storage/async_variants/representations_redirect_controller_extension.rb +0 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed31f4592e3d865609aef29b42657856321528d5013bb2a1ec499e3de93c5bb4
4
- data.tar.gz: 994460c1ae175245da3d71489b25d4143827931f926289c94c18957df8cf0610
3
+ metadata.gz: bc926d64601c238db11edd005f220c0842cbf4316f405d43c23d08a99ff874de
4
+ data.tar.gz: 177a3d4a3647e41ac5e23db1908265c556a8c4c006b5497cbb802b2cf9ebb02e
5
5
  SHA512:
6
- metadata.gz: c6b94095e095899175ac9fa37497500b9947e761d2ed07a7fdd78d55a90ac05898dca086e02d8b29419fc1fde8567628a4f6c7444470826ae6fb0556220af4ef
7
- data.tar.gz: 109f1670b0ad22ce670bf27751ffeac462f0f10c94d0d89f4777c211b2fb1aa179c3981373ebc9b2f36a2262c4722dcdf73cd54ff95ca29cf6b035765150357f
6
+ metadata.gz: 25f4d2430ce2547c9a4109bd648f338914e92cacdb38bf2e651cdfff21a9a045e90480c10e6170f0723dbcb5e675a35370cda7c91ced43ec41c5bfa3f112f2a1
7
+ data.tar.gz: c02fac50058fd3465435bf194e09ffb0732deda2c61316b8e960b1179e4ee8e9eb417e1dd192fdf564c0e059d87a674fe76aa852f99a385592ddfcd71fb1a453
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.5.0]
2
+
3
+ - Added an opt-in retry affordance to the failed state: hovering a failed variant reveals a control that opens a dialog with the error and a "Retry processing" button that re-runs the transform. Disabled by default; enable it with `ActiveStorage::AsyncVariants.retry_visible_if { … }` (the block runs in the view context, so it can check `current_user`).
4
+
5
+ ## [0.4.0]
6
+
7
+ - **Breaking:** Replaced the polling-`<img>` architecture with a `<turbo-frame>` for unprocessed variants. `image_tag`/`video_tag` with `async: true` now emits a normal `<img>`/`<video>` when the variant is already processed, and a `<turbo-frame src="…">` otherwise. The frame hits a new gem-shipped endpoint (`GET /active_storage/async_variants/states/:signed_blob_id/:variation_key`) that renders one of `_processing`/`_failed`/`_processed` partials. Non-terminal renders include an inline `<script>` that schedules the frame's next reload, so the state polls itself until it terminates.
8
+ - New `app/views/active_storage/async_variants/states/` partial set — apps can override any partial by creating a same-named file in their own `app/views/active_storage/async_variants/states/`. The processing state defaults to a bare `<img>`/`<video>` pointing at the variant's URL, which the gem redirects to the app's configured `processing:` SVG.
9
+ - All CSS and JavaScript ship inline in the per-response state partials — no asset-pipeline footprint. Apps don't need to include any stylesheet or script.
10
+ - **Breaking:** `Variant#processed` and `Preview#processed` are now no-ops on bucket-backed services (they previously also lazy-enqueued `ProcessJob` for any record that wasn't already processed). Enqueue now happens only at attachment time (via `AttachmentExtension#transform_variants_later`). Pre-existing blobs that missed the auto-enqueue won't self-heal on first view; backfill via a rake task.
11
+ - New public `#enqueue!` method on both `Variant` and `Preview` with the same signature — no `respond_to?` dance. Creates a pending `VariantRecord` (RecordNotUnique guards dedupe) and dispatches `ProcessJob`. Replaces the previously private `enqueue_processing` / `enqueue_async_preview`.
12
+ - The named-variant lookup now walks one level back through `preview_image` attachments, so a request for a Variant of a video's extracted preview frame can recover the named-variant declaration from the parent record's source-video field. Fixes nil-URL 500s in dev where the cold Registry can't resolve a redirect-controller request.
13
+ - Added `turbo-rails >= 2.0` as a runtime dependency.
14
+ - Removed `RepresentationsRedirectControllerExtension`, the `[data-async-variant-*]` polling JS, the bundled `app/assets/stylesheets/active_storage_async_variants.css`, the engine's asset precompile initializer, and the `apply_async_data!` helper machinery. Consumers depending on those data attributes must migrate to targeting the new `.async-variant-state` classes or override the partials.
15
+
1
16
  ## [0.3.1]
2
17
 
3
18
  - Bound the stored variant `error` to 16k chars at both write sites (the failed-status callback and `ProcessJob`'s rescue). An external transformer reporting a >64KB error payload was overflowing the `TEXT` column and 500ing the callback, leaving the variant stuck instead of marked failed.
data/README.md CHANGED
@@ -97,29 +97,9 @@ ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"
97
97
 
98
98
  The resulting URL is `"#{cdn_host}/#{variant.key}"`.
99
99
 
100
- ### Wiring the JavaScript
100
+ ### JavaScript
101
101
 
102
- The gem ships a dependency-free Stimulus-like controller at `app/assets/javascripts/active_storage_async_variants.js`. It is registered with the asset pipeline by the engine but not auto-imported. Wire it up the same way as `@rails/activestorage` -- pick whichever fits your bundler:
103
-
104
- Using **importmap-rails**:
105
-
106
- ```ruby
107
- # config/importmap.rb
108
- pin "@active_storage/async_variants", to: "active_storage_async_variants.js", preload: true
109
- ```
110
-
111
- ```js
112
- // app/javascript/application.js
113
- import "@active_storage/async_variants"
114
- ```
115
-
116
- Using **the asset pipeline** (sprockets/propshaft) with a classic `javascript_include_tag`:
117
-
118
- ```erb
119
- <%= javascript_include_tag "active_storage_async_variants" %>
120
- ```
121
-
122
- The module auto-starts on `DOMContentLoaded` and finds elements with `data-async-variant-state-value`. Opt out by setting `window.ActiveStorageAsyncVariants = null` before the script loads, then calling `start()` yourself from the exported module when ready.
102
+ No manual wiring is required. The async state partials are self-contained `<turbo-frame>`s. The only requirement is that the host app loads **Turbo** -- the gem depends on `turbo-rails`, which a default Rails app already includes.
123
103
 
124
104
  ## Writing a Transformer
125
105
 
data/Rakefile CHANGED
@@ -5,4 +5,12 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- task default: :spec
8
+ desc "Run browser-level acceptance tests (cucumber + cuprite against spec/dummy)"
9
+ task :cucumber do
10
+ sh "bundle exec cucumber"
11
+ end
12
+
13
+ desc "Run rspec and cucumber"
14
+ task all: [:spec, :cucumber]
15
+
16
+ task default: :all
@@ -0,0 +1,11 @@
1
+ // Declarative shadow roots are only attached by the initial HTML parser, not by
2
+ // fragment insertion (innerHTML / Turbo frame swaps), so promote them on connect.
3
+ customElements.define("async-variant-retry", class extends HTMLElement {
4
+ connectedCallback() {
5
+ if (this.shadowRoot) return
6
+ const template = this.querySelector(":scope > template[shadowrootmode]")
7
+ if (!template) return
8
+ this.attachShadow({ mode: template.getAttribute("shadowrootmode") }).append(template.content)
9
+ template.remove()
10
+ }
11
+ })
@@ -0,0 +1,98 @@
1
+ /* Scoped to the retry dialog's shadow root (linked inside its <template>). */
2
+ :host { display: contents; }
3
+
4
+ button { font: inherit; cursor: pointer; }
5
+
6
+ .opener {
7
+ position: absolute;
8
+ top: 6px; right: 6px;
9
+ width: 22px; height: 22px;
10
+ background: #2b7cd6;
11
+ color: #fff;
12
+ border: 2px solid #fff;
13
+ border-radius: 50%;
14
+ font-size: 13px;
15
+ font-weight: bold;
16
+ line-height: 1;
17
+ padding: 0;
18
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
19
+ display: var(--retry-opener-display, none);
20
+ align-items: center;
21
+ justify-content: center;
22
+ }
23
+ .opener:hover { background: #1a5fb0; }
24
+
25
+ dialog {
26
+ width: 720px;
27
+ max-width: 90vw;
28
+ max-height: 80vh;
29
+ padding: 0;
30
+ border: 1px solid #999;
31
+ border-radius: 6px;
32
+ background: #fff;
33
+ color: #222;
34
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
35
+ font: 13px/1.4 system-ui, -apple-system, sans-serif;
36
+ }
37
+ dialog[open] { display: flex; flex-direction: column; }
38
+ dialog::backdrop { background: rgba(0, 0, 0, 0.5); }
39
+
40
+ dialog > button {
41
+ position: absolute;
42
+ top: 10px;
43
+ right: 12px;
44
+ width: 32px; height: 32px;
45
+ background: transparent;
46
+ border: none;
47
+ color: #888;
48
+ font-size: 24px;
49
+ line-height: 1;
50
+ padding: 0;
51
+ border-radius: 4px;
52
+ z-index: 1;
53
+ }
54
+ dialog > button:hover { color: #000; background: rgba(0, 0, 0, 0.08); }
55
+
56
+ header { padding: 20px 24px 0 24px; }
57
+ header h3 {
58
+ margin: 0;
59
+ font-size: 16px;
60
+ font-weight: bold;
61
+ color: #111;
62
+ padding-right: 32px;
63
+ }
64
+
65
+ section {
66
+ flex: 1 1 auto;
67
+ overflow: auto;
68
+ padding: 12px 24px 20px 24px;
69
+ min-height: 0;
70
+ }
71
+ section pre {
72
+ background: #f5f5f5;
73
+ color: #222;
74
+ border: 1px solid #ddd;
75
+ padding: 8px;
76
+ font: 11px/1.4 ui-monospace, Menlo, monospace;
77
+ white-space: pre-wrap;
78
+ word-break: break-all;
79
+ margin: 0;
80
+ }
81
+
82
+ footer {
83
+ flex: 0 0 auto;
84
+ border-top: 1px solid #ddd;
85
+ padding: 12px 20px;
86
+ display: flex;
87
+ justify-content: flex-end;
88
+ background: #fafafa;
89
+ }
90
+ footer button {
91
+ padding: 8px 16px;
92
+ background: #2a7;
93
+ color: #fff;
94
+ border: none;
95
+ border-radius: 4px;
96
+ font-weight: bold;
97
+ }
98
+ footer button:disabled { opacity: 0.5; cursor: wait; }
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ class StatesController < ActiveStorage::AsyncVariants.parent_controller.constantize
6
+ # signed_blob_id + variation_key in URL act as CSRF: both require app's secret.
7
+ skip_forgery_protection
8
+
9
+ helper "turbo/frames"
10
+ helper ActiveStorage::AsyncVariants::Helper
11
+
12
+ layout false
13
+
14
+ before_action :set_variant
15
+
16
+ def show
17
+ end
18
+
19
+ def retry
20
+ @variant.blob.variant_records.where(
21
+ variation_digest: @variant.variation.digest,
22
+ state: "failed",
23
+ ).destroy_all
24
+ @variant.enqueue!
25
+
26
+ redirect_to Rails.application.routes.url_helpers.async_variant_state_path(
27
+ signed_blob_id: params[:signed_blob_id],
28
+ variation_key: params[:variation_key],
29
+ kind: params[:kind],
30
+ direct: params[:direct],
31
+ opts: async_variant_html_options.presence,
32
+ ), status: :see_other
33
+ end
34
+
35
+ helper_method :async_variant_kind, :async_variant_direct?, :async_variant_html_options
36
+
37
+ private
38
+
39
+ def set_variant
40
+ blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
41
+ variation = ActiveStorage::Variation.decode(params[:variation_key])
42
+ @variant = blob.variant(variation)
43
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
44
+ head :not_found
45
+ end
46
+
47
+ def async_variant_kind
48
+ params[:kind].to_s == "video" ? :video : :image
49
+ end
50
+
51
+ def async_variant_direct?
52
+ ActiveModel::Type::Boolean.new.cast(params[:direct])
53
+ end
54
+
55
+ def async_variant_html_options
56
+ params[:opts]
57
+ &.permit(*ActiveStorage::AsyncVariants::PASS_THROUGH_HTML_OPTIONS)
58
+ .to_h
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,43 @@
1
+ <div class="async-variant-state async-variant-failed">
2
+ <%= image_tag async_variant_representation_path(variant), **html_options.symbolize_keys.except(:controls, :autoplay, :preload) %>
3
+
4
+ <% if ActiveStorage::AsyncVariants.retry_visible?(self) %>
5
+ <% dialog_id = "#{async_variant_frame_id(variant)}-error" %>
6
+ <% form_id = "#{dialog_id}-form" %>
7
+ <%# Light DOM, so the custom property inherits into the shadow root. %>
8
+ <style>
9
+ .async-variant-failed {
10
+ position: relative;
11
+ display: inline-block;
12
+ --retry-opener-display: none;
13
+ }
14
+ .async-variant-failed:hover { --retry-opener-display: inline-flex; }
15
+ </style>
16
+ <%# In the light DOM so its submit reaches Turbo, and so preventDefault on the
17
+ buttons doesn't also cancel the submit. %>
18
+ <form action="<%= Rails.application.routes.url_helpers.async_variant_state_retry_path(signed_blob_id:, variation_key:, kind:, direct:, opts: html_options) %>"
19
+ method="post"
20
+ id="<%= form_id %>"
21
+ data-turbo-frame="<%= async_variant_frame_id(variant) %>"
22
+ hidden></form>
23
+ <async-variant-retry>
24
+ <template shadowrootmode="open">
25
+ <%= ActiveStorage::AsyncVariants.stylesheet_link_tag "retry" %>
26
+ <button class="opener" type="button"
27
+ onclick="event.preventDefault(); event.stopPropagation(); this.getRootNode().getElementById('<%= dialog_id %>').showModal()"
28
+ title="Variant processing failed — click for details">?</button>
29
+ <dialog id="<%= dialog_id %>">
30
+ <button type="button"
31
+ onclick="event.preventDefault(); event.stopPropagation(); this.closest('dialog').close()">&times;</button>
32
+ <header><h3>Variant processing failed</h3></header>
33
+ <section><pre><%= variant.error %></pre></section>
34
+ <footer>
35
+ <button type="button"
36
+ onclick="event.preventDefault(); event.stopPropagation(); this.disabled = true; this.textContent = 'Retrying…'; document.getElementById('<%= form_id %>').requestSubmit()">Retry processing</button>
37
+ </footer>
38
+ </dialog>
39
+ </template>
40
+ </async-variant-retry>
41
+ <%= ActiveStorage::AsyncVariants.javascript_include_tag "retry", type: "module" %>
42
+ <% end %>
43
+ </div>
@@ -0,0 +1 @@
1
+ <%= render "active_storage/async_variants/states/processing", local_assigns %>
@@ -0,0 +1,5 @@
1
+ <%
2
+ method = kind == :video ? :video_tag : :image_tag
3
+ src = direct ? async_variant_direct_url(variant) : async_variant_representation_path(variant)
4
+ %>
5
+ <%= send method, src, **html_options.symbolize_keys %>
@@ -0,0 +1,9 @@
1
+ <div class="async-variant-state async-variant-processing">
2
+ <%= image_tag async_variant_representation_path(variant), **html_options.symbolize_keys.except(:controls, :autoplay, :preload) %>
3
+ </div>
4
+ <%# Self-perpetuating poll: Turbo runs <script>s in frame swaps, so each
5
+ pending/processing response schedules its own next reload. Terminal
6
+ partials (_failed, _processed) emit no script -> chain stops. %>
7
+ <script>
8
+ setTimeout(() => document.getElementById("<%= async_variant_frame_id(variant) %>")?.reload(), 3000)
9
+ </script>
@@ -0,0 +1,10 @@
1
+ <%= turbo_frame_tag async_variant_frame_id(@variant) do %>
2
+ <%= render "active_storage/async_variants/states/#{@variant.async_state}", {
3
+ variant: @variant,
4
+ html_options: async_variant_html_options,
5
+ kind: async_variant_kind,
6
+ direct: async_variant_direct?,
7
+ signed_blob_id: params[:signed_blob_id],
8
+ variation_key: params[:variation_key],
9
+ } %>
10
+ <% end %>
data/config/routes.rb CHANGED
@@ -4,4 +4,13 @@ Rails.application.routes.draw do
4
4
  post "/active_storage/async_variants/callbacks/:token",
5
5
  to: "active_storage/async_variants/callbacks#create",
6
6
  as: :active_storage_async_variant_callback
7
+
8
+ get "/active_storage/async_variants/states/:signed_blob_id/:variation_key",
9
+ to: "active_storage/async_variants/states#show",
10
+ as: :async_variant_state
11
+ post "/active_storage/async_variants/states/:signed_blob_id/:variation_key/retry",
12
+ to: "active_storage/async_variants/states#retry",
13
+ as: :async_variant_state_retry
14
+
15
+ ActiveStorage::AsyncVariants::Assets.draw(self, "/active_storage/async_variants/assets")
7
16
  end
@@ -0,0 +1,20 @@
1
+ Feature: Clicking the retry affordance opens a dialog and resubmits the variant
2
+
3
+ Background:
4
+ Given a user with an attached avatar
5
+ And the retry affordance is visible to everyone
6
+
7
+ Scenario: Clicking the opener reveals the error inside the shadow-DOM dialog
8
+ Given the avatar's :thumb_proc variant is in failed state with error "boom from upstream"
9
+ When I visit the avatar page for the :thumb_proc variant
10
+ And I click the retry opener
11
+ Then the failure dialog should be open
12
+ And the failure dialog should contain "boom from upstream"
13
+
14
+ Scenario: Submitting Retry destroys the failed record and re-enqueues
15
+ Given the avatar's :thumb_proc variant is in failed state with error "boom"
16
+ When I visit the avatar page for the :thumb_proc variant
17
+ And I click the retry opener
18
+ And I click "Retry processing"
19
+ Then the frame should render the processing state
20
+ And the page should not have navigated away
@@ -0,0 +1,23 @@
1
+ Feature: The retry affordance is gated by ActiveStorage::AsyncVariants.retry_visible_if
2
+
3
+ Scenario: When retry_visible_if returns true, the retry chrome renders
4
+ Given a user with an attached avatar
5
+ And the retry affordance is visible to everyone
6
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
7
+ When I visit the avatar page for the :thumb_proc variant
8
+ Then the retry affordance should be visible
9
+
10
+ Scenario: When retry_visible_if returns false, the retry chrome is omitted
11
+ Given a user with an attached avatar
12
+ And the retry affordance is hidden
13
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
14
+ When I visit the avatar page for the :thumb_proc variant
15
+ Then the retry affordance should NOT be visible
16
+
17
+ Scenario: A proc that consults current_user gates the affordance per-viewer
18
+ Given a user with an attached avatar
19
+ And the retry affordance requires a signed-in user
20
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
21
+ When I sign in as the user
22
+ And I visit the avatar page for the :thumb_proc variant
23
+ Then the retry affordance should be visible
@@ -0,0 +1,22 @@
1
+ Feature: image_tag with async: true emits the right markup per variant state
2
+
3
+ Scenario: A processed variant emits a plain <img> -- no turbo-frame, no chrome
4
+ Given a user with an attached avatar
5
+ And the avatar's :thumb_proc variant is in processed state
6
+ When I visit the avatar page for the :thumb_proc variant
7
+ Then the page should NOT contain a turbo-frame
8
+
9
+ Scenario: An unprocessed variant emits a turbo-frame in processing state
10
+ Given a user with an attached avatar
11
+ And the avatar's :thumb_proc variant is in processing state
12
+ When I visit the avatar page for the :thumb_proc variant
13
+ Then the page should contain a turbo-frame
14
+ And the frame should render the processing state
15
+
16
+ Scenario: A failed variant renders the failed partial inside the frame
17
+ Given a user with an attached avatar
18
+ And the retry affordance is visible to everyone
19
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
20
+ When I visit the avatar page for the :thumb_proc variant
21
+ Then the page should contain a turbo-frame
22
+ And the frame should render the failed state
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ Given /^a user with an attached avatar$/ do
4
+ @user = User.create!
5
+ attach_avatar_to(@user)
6
+ end
7
+
8
+ Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed) state(?: with error "([^"]+)")?$/ do |variant_name, state, error|
9
+ variant = @user.avatar.variant(variant_name.to_sym)
10
+ if state == "processed"
11
+ simulate_processed_variant(variant)
12
+ else
13
+ create_variant_record(variant, state: state, error: error)
14
+ end
15
+ end
16
+
17
+ Given /^the retry affordance is visible to everyone$/ do
18
+ ActiveStorage::AsyncVariants.retry_visible_if { true }
19
+ end
20
+
21
+ Given /^the retry affordance is hidden$/ do
22
+ ActiveStorage::AsyncVariants.retry_visible_if { false }
23
+ end
24
+
25
+ Given /^the retry affordance requires a signed-in user$/ do
26
+ ActiveStorage::AsyncVariants.retry_visible_if { current_user.present? }
27
+ end
28
+
29
+ When /^I sign in as the (admin|user)$/ do |_kind|
30
+ visit "/session/#{@user.id}"
31
+ end
32
+
33
+ When /^I visit the avatar page for the :(\w+) variant$/ do |variant_name|
34
+ visit "/avatars/#{@user.id}/#{variant_name}"
35
+ end
36
+
37
+ Then /^the page should contain a turbo-frame$/ do
38
+ expect(page).to have_css("turbo-frame")
39
+ end
40
+
41
+ Then /^the page should NOT contain a turbo-frame$/ do
42
+ expect(page).to have_no_css("turbo-frame")
43
+ expect(page).to have_css("img")
44
+ end
45
+
46
+ Then /^the frame should render the (failed|processing|processed) state$/ do |state|
47
+ expect(page).to have_css("turbo-frame .async-variant-#{state}")
48
+ end
49
+
50
+ Then /^the retry affordance should be visible$/ do
51
+ expect(page).to have_css("async-variant-retry", visible: :all)
52
+ end
53
+
54
+ Then /^the retry affordance should NOT be visible$/ do
55
+ expect(page).to have_no_css("async-variant-retry", visible: :all)
56
+ end
57
+
58
+ Then /^the page should not have navigated away$/ do
59
+ expect(page).to have_css("h1#page-marker")
60
+ end
61
+
62
+ Then /^the failure dialog should be open$/ do
63
+ expect(page).to have_css("async-variant-retry", visible: :all)
64
+ open = page.evaluate_script(<<~JS)
65
+ (() => {
66
+ const host = document.querySelector("async-variant-retry")
67
+ if (!host || !host.shadowRoot) return false
68
+ const dialog = host.shadowRoot.querySelector("dialog")
69
+ return dialog ? dialog.open : false
70
+ })()
71
+ JS
72
+ expect(open).to eq(true)
73
+ end
74
+
75
+ Then /^the failure dialog should contain "([^"]+)"$/ do |text|
76
+ contains = page.evaluate_script(<<~JS)
77
+ (() => {
78
+ const host = document.querySelector("async-variant-retry")
79
+ if (!host || !host.shadowRoot) return false
80
+ return host.shadowRoot.textContent.includes(#{text.inspect})
81
+ })()
82
+ JS
83
+ expect(contains).to eq(true)
84
+ end
85
+
86
+ # Wait for Turbo to swap the frame and our shim to attach the shadow root.
87
+ def wait_for_shadow!
88
+ Timeout.timeout(5) do
89
+ loop do
90
+ attached = page.evaluate_script("!!document.querySelector('async-variant-retry')?.shadowRoot")
91
+ break if attached
92
+ sleep 0.05
93
+ end
94
+ end
95
+ end
96
+
97
+ When /^I click the retry opener$/ do
98
+ wait_for_shadow!
99
+ page.evaluate_script(<<~JS)
100
+ document.querySelector("async-variant-retry").shadowRoot.querySelector(".opener").click()
101
+ JS
102
+ end
103
+
104
+ When /^I click "Retry processing"$/ do
105
+ wait_for_shadow!
106
+ page.evaluate_script(<<~JS)
107
+ document.querySelector("async-variant-retry").shadowRoot.querySelector("footer button").click()
108
+ JS
109
+ end
110
+
111
+ When /^I close the failure dialog$/ do
112
+ wait_for_shadow!
113
+ page.evaluate_script(<<~JS)
114
+ document.querySelector("async-variant-retry").shadowRoot.querySelector("dialog > button[type=button]").click()
115
+ JS
116
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["RAILS_ENV"] ||= "test"
4
+ require File.expand_path("../../spec/dummy/config/environment", __dir__)
5
+
6
+ require "capybara"
7
+ require "capybara/cucumber"
8
+ require "capybara/cuprite"
9
+ require_relative "../../spec/support/schema"
10
+
11
+ DummySchema.load!
12
+
13
+ Capybara.register_driver(:cuprite) do |app|
14
+ Capybara::Cuprite::Driver.new(app, window_size: [1200, 900], timeout: 10, process_timeout: 20, js_errors: true)
15
+ end
16
+ Capybara.app = Rails.application
17
+ Capybara.javascript_driver = :cuprite
18
+ Capybara.default_driver = :cuprite
19
+ Capybara.server = :puma, { Silent: true }
20
+
21
+ ActionController::Base.allow_forgery_protection = false
22
+
23
+ # Test adapter just records enqueued jobs -- we don't want them to actually run
24
+ # during browser tests (they'd call real transformers and modify variant_records
25
+ # behind the test's back). The retry step seeds the new state explicitly.
26
+ ActiveJob::Base.queue_adapter = :test
27
+
28
+ Before { DummySchema.cleanup! }
@@ -10,5 +10,9 @@ gem "simplecov", require: false
10
10
  gem "rspec-rails"
11
11
  gem "rails", "~> 7.2.0"
12
12
  gem "sqlite3"
13
+ gem "cucumber"
14
+ gem "capybara"
15
+ gem "cuprite"
16
+ gem "puma"
13
17
 
14
18
  gemspec path: "../"
@@ -10,5 +10,9 @@ gem "simplecov", require: false
10
10
  gem "rspec-rails"
11
11
  gem "rails", "~> 8.0.0"
12
12
  gem "sqlite3"
13
+ gem "cucumber"
14
+ gem "capybara"
15
+ gem "cuprite"
16
+ gem "puma"
13
17
 
14
18
  gemspec path: "../"
@@ -10,5 +10,9 @@ gem "simplecov", require: false
10
10
  gem "rspec-rails"
11
11
  gem "rails", "~> 8.1.0"
12
12
  gem "sqlite3"
13
+ gem "cucumber"
14
+ gem "capybara"
15
+ gem "cuprite"
16
+ gem "puma"
13
17
 
14
18
  gemspec path: "../"