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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +2 -22
- data/Rakefile +9 -1
- data/app/assets/javascripts/retry.js +11 -0
- data/app/assets/stylesheets/retry.css +98 -0
- data/app/controllers/active_storage/async_variants/states_controller.rb +62 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +43 -0
- data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
- data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
- data/app/views/active_storage/async_variants/states/_processing.html.erb +9 -0
- data/app/views/active_storage/async_variants/states/show.html.erb +10 -0
- data/config/routes.rb +9 -0
- data/features/retry_flow.feature +20 -0
- data/features/retry_visibility.feature +23 -0
- data/features/state_rendering.feature +22 -0
- data/features/step_definitions/dummy_steps.rb +116 -0
- data/features/support/env.rb +28 -0
- data/gemfiles/rails_7.2.gemfile +4 -0
- data/gemfiles/rails_8.0.gemfile +4 -0
- data/gemfiles/rails_8.1.gemfile +4 -0
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +38 -56
- data/lib/active_storage/async_variants/helper.rb +59 -0
- data/lib/active_storage/async_variants/preview_extension.rb +35 -32
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +40 -30
- data/lib/active_storage/async_variants/version.rb +1 -1
- data/lib/active_storage/async_variants.rb +26 -15
- metadata +44 -4
- data/app/assets/javascripts/active_storage_async_variants.js +0 -169
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc926d64601c238db11edd005f220c0842cbf4316f405d43c23d08a99ff874de
|
|
4
|
+
data.tar.gz: 177a3d4a3647e41ac5e23db1908265c556a8c4c006b5497cbb802b2cf9ebb02e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
###
|
|
100
|
+
### JavaScript
|
|
101
101
|
|
|
102
|
-
|
|
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
|
-
|
|
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()">×</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,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! }
|
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.0.gemfile
CHANGED