active_storage-async_variants 0.8.0 → 0.9.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.
@@ -1,40 +0,0 @@
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 processing variant renders an indeterminate progress bar before any heartbeat
17
- Given a user with an attached avatar
18
- And the avatar's :thumb_proc variant is in processing state
19
- When I visit the avatar page for the :thumb_proc variant
20
- Then the page should contain a turbo-frame
21
- And the frame should render a progress bar
22
- And the progress bar should be indeterminate
23
- And the placeholder image should reserve layout
24
-
25
- Scenario: A processing variant with reported progress renders a determinate progress bar
26
- Given a user with an attached avatar
27
- And the avatar's :thumb_proc variant is in processing state
28
- And the avatar's :thumb_proc variant has reported 42% progress
29
- When I visit the avatar page for the :thumb_proc variant
30
- Then the page should contain a turbo-frame
31
- And the progress bar should show 42%
32
-
33
- Scenario: A failed variant renders the failed partial inside the frame
34
- Given a user with an attached avatar
35
- And the retry affordance is visible to everyone
36
- And the avatar's :thumb_proc variant is in failed state with error "boom"
37
- When I visit the avatar page for the :thumb_proc variant
38
- Then the page should contain a turbo-frame
39
- And the frame should render the failed state
40
- And the frame should render an error progress bar
@@ -1,144 +0,0 @@
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 avatar's :(\w+) variant has reported (\d+)% progress$/ do |variant_name, percent|
18
- variant = @user.avatar.variant(variant_name.to_sym)
19
- record = variant.blob.variant_records.find_by(variation_digest: variant.variation.digest)
20
- record.update!(progress: percent.to_i)
21
- end
22
-
23
- Then /^the frame should render a progress bar$/ do
24
- expect(page).to have_css("turbo-frame progress-bar", visible: :all)
25
- end
26
-
27
- Then /^the frame should render an error progress bar$/ do
28
- expect(page).to have_css("turbo-frame progress-bar[error]", visible: :all)
29
- end
30
-
31
- Then /^the progress bar should be indeterminate$/ do
32
- expect(page).to have_css("turbo-frame progress-bar:not([percent])", visible: :all)
33
- end
34
-
35
- Then /^the progress bar should show (\d+)%$/ do |percent|
36
- expect(page).to have_css(%(turbo-frame progress-bar[percent="#{percent}"]), visible: :all)
37
- end
38
-
39
- # Default (visible-only) matcher: passes only because the placeholder is hidden
40
- # via opacity (not visibility/display), so Capybara still sees it.
41
- Then /^the placeholder image should reserve layout$/ do
42
- expect(page).to have_css("turbo-frame .async-variant-processing img")
43
- end
44
-
45
- Given /^the retry affordance is visible to everyone$/ do
46
- ActiveStorage::AsyncVariants.retry_visible_if { true }
47
- end
48
-
49
- Given /^the retry affordance is hidden$/ do
50
- ActiveStorage::AsyncVariants.retry_visible_if { false }
51
- end
52
-
53
- Given /^the retry affordance requires a signed-in user$/ do
54
- ActiveStorage::AsyncVariants.retry_visible_if { current_user.present? }
55
- end
56
-
57
- When /^I sign in as the (admin|user)$/ do |_kind|
58
- visit "/session/#{@user.id}"
59
- end
60
-
61
- When /^I visit the avatar page for the :(\w+) variant$/ do |variant_name|
62
- visit "/avatars/#{@user.id}/#{variant_name}"
63
- end
64
-
65
- Then /^the page should contain a turbo-frame$/ do
66
- expect(page).to have_css("turbo-frame")
67
- end
68
-
69
- Then /^the page should NOT contain a turbo-frame$/ do
70
- expect(page).to have_no_css("turbo-frame")
71
- expect(page).to have_css("img")
72
- end
73
-
74
- Then /^the frame should render the (failed|processing|processed) state$/ do |state|
75
- expect(page).to have_css("turbo-frame .async-variant-#{state}")
76
- end
77
-
78
- Then /^the retry affordance should be visible$/ do
79
- expect(page).to have_css("async-variant-retry", visible: :all)
80
- end
81
-
82
- Then /^the retry affordance should NOT be visible$/ do
83
- expect(page).to have_no_css("async-variant-retry", visible: :all)
84
- end
85
-
86
- Then /^the page should not have navigated away$/ do
87
- expect(page).to have_css("h1#page-marker")
88
- end
89
-
90
- Then /^the failure dialog should be open$/ do
91
- expect(page).to have_css("async-variant-retry", visible: :all)
92
- open = page.evaluate_script(<<~JS)
93
- (() => {
94
- const host = document.querySelector("async-variant-retry")
95
- if (!host || !host.shadowRoot) return false
96
- const dialog = host.shadowRoot.querySelector("dialog")
97
- return dialog ? dialog.open : false
98
- })()
99
- JS
100
- expect(open).to eq(true)
101
- end
102
-
103
- Then /^the failure dialog should contain "([^"]+)"$/ do |text|
104
- contains = page.evaluate_script(<<~JS)
105
- (() => {
106
- const host = document.querySelector("async-variant-retry")
107
- if (!host || !host.shadowRoot) return false
108
- return host.shadowRoot.textContent.includes(#{text.inspect})
109
- })()
110
- JS
111
- expect(contains).to eq(true)
112
- end
113
-
114
- # Wait for Turbo to swap the frame and our shim to attach the shadow root.
115
- def wait_for_shadow!
116
- Timeout.timeout(5) do
117
- loop do
118
- attached = page.evaluate_script("!!document.querySelector('async-variant-retry')?.shadowRoot")
119
- break if attached
120
- sleep 0.05
121
- end
122
- end
123
- end
124
-
125
- When /^I click the retry opener$/ do
126
- wait_for_shadow!
127
- page.evaluate_script(<<~JS)
128
- document.querySelector("async-variant-retry").shadowRoot.querySelector(".opener").click()
129
- JS
130
- end
131
-
132
- When /^I click "Retry processing"$/ do
133
- wait_for_shadow!
134
- page.evaluate_script(<<~JS)
135
- document.querySelector("async-variant-retry").shadowRoot.querySelector("footer button").click()
136
- JS
137
- end
138
-
139
- When /^I close the failure dialog$/ do
140
- wait_for_shadow!
141
- page.evaluate_script(<<~JS)
142
- document.querySelector("async-variant-retry").shadowRoot.querySelector("dialog > button[type=button]").click()
143
- JS
144
- end
@@ -1,28 +0,0 @@
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! }
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveStorage
4
- module AsyncVariants
5
- # Prepended onto ActionView::Helpers::AssetTagHelper. Adds `async:` and
6
- # `direct:` options to image_tag/video_tag; routes unprocessed variants
7
- # through a <turbo-frame> whose body lives in StatesController#show.
8
- module AssetTagHelperExtension
9
- include ActiveStorage::AsyncVariants::Helper
10
-
11
- def image_tag(source, options = {})
12
- async_variant_tag(:image, [source], options) do |urls, opts|
13
- super(urls.first, opts)
14
- end
15
- end
16
-
17
- def video_tag(*sources)
18
- options = sources.extract_options!
19
- async_variant_tag(:video, sources, options) do |urls, opts|
20
- super(*urls, opts)
21
- end
22
- end
23
-
24
- private
25
-
26
- def async_variant_tag(kind, sources, options)
27
- options = options.symbolize_keys
28
- async = options.delete(:async)
29
- direct = options.delete(:direct)
30
- return yield(sources, options) if !async && !direct
31
-
32
- variant, *rest = sources
33
- assert_async_variant!(variant)
34
-
35
- if async && !async_variant_processed_inline?(variant)
36
- async_variant_turbo_frame(variant, kind:, direct:, html_options: options)
37
- else
38
- yield [async_variant_resolved_src(variant, direct:), *rest], options
39
- end
40
- end
41
-
42
- def assert_async_variant!(source)
43
- unless source.is_a?(ActiveStorage::VariantWithRecord) || source.is_a?(ActiveStorage::Preview)
44
- raise ArgumentError, "image_tag/video_tag with async:/direct: requires an ActiveStorage::VariantWithRecord or Preview, got #{source.class}"
45
- end
46
- end
47
-
48
- def async_variant_turbo_frame(variant, kind:, direct:, html_options:)
49
- content_tag(
50
- :"turbo-frame",
51
- "",
52
- id: async_variant_frame_id(variant),
53
- src: async_variant_frame_src(variant, kind: kind, direct: direct, html_options: html_options),
54
- refresh: "morph",
55
- )
56
- end
57
- end
58
- end
59
- end
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveStorage
4
- module AsyncVariants
5
- # View/controller helper methods. Included in the AssetTagHelperExtension
6
- # (so image_tag/video_tag can use them) and added to StatesController via
7
- # `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
8
- # use them).
9
- module Helper
10
- # Invisible box reserving layout for the progress bar. The image src is a
11
- # tiny gem GIF whose URL ends in the media filename (no original fetched);
12
- # the video placeholder carries no <source>.
13
- def async_variant_placeholder_tag(variant, html_options = {}, kind: :image)
14
- opts = async_variant_box_dimensions(variant)
15
- .merge(html_options.symbolize_keys.except(:src, :controls, :autoplay, :preload, :poster))
16
- if kind == :video
17
- content_tag(:video, "", opts)
18
- else
19
- src = Rails.application.routes.url_helpers.async_variant_placeholder_path(variant.blob.filename.to_s)
20
- image_tag(src, opts)
21
- end
22
- end
23
-
24
- def async_variant_box_dimensions(variant)
25
- resize = variant.variation.transformations
26
- .values_at(:resize_to_limit, :resize_to_fit, :resize_to_fill)
27
- .compact.first
28
- resize.is_a?(Array) ? { width: resize[0], height: resize[1] } : {}
29
- end
30
-
31
- # In test with non-bucket-backed services, the gem defers to vanilla
32
- # ActiveStorage (synchronous vips transform) -- inline rendering keeps
33
- # those environments simple. Otherwise, only inline a normal <img> when
34
- # the variant has reached the processed terminal state.
35
- def async_variant_processed_inline?(variant)
36
- !variant.blob.bucket_backed? || variant.async_state == "processed"
37
- end
38
-
39
- def async_variant_resolved_src(variant, direct:)
40
- if direct && variant.async_state == "processed"
41
- async_variant_direct_url(variant)
42
- else
43
- variant.processed if variant.blob.bucket_backed?
44
- async_variant_representation_path(variant)
45
- end
46
- end
47
-
48
- def async_variant_frame_id(variant)
49
- digest = variant.variation.digest.gsub(/[^a-zA-Z0-9_-]/, "")
50
- "async-variant-#{variant.blob.id}-#{digest}"
51
- end
52
-
53
- def async_variant_frame_src(variant, kind:, direct:, html_options: {})
54
- async_variant_state_path(
55
- signed_blob_id: variant.blob.signed_id,
56
- variation_key: variant.variation.key,
57
- kind:,
58
- direct:,
59
- opts: html_options.slice(*PASS_THROUGH_HTML_OPTIONS),
60
- )
61
- end
62
-
63
- def async_variant_direct_url(variant)
64
- if cdn = ActiveStorage::AsyncVariants.cdn_host
65
- "#{cdn}/#{variant.key}"
66
- else
67
- variant.image.url
68
- end
69
- end
70
-
71
- def async_variant_representation_path(variant)
72
- Rails.application.routes.url_helpers.rails_blob_representation_path(
73
- variant.blob.signed_id,
74
- variant.variation.key,
75
- variant.blob.filename.to_s,
76
- )
77
- end
78
- end
79
- end
80
- end