snapshot_ui-rails 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0cf0d4ed677d5728c73352d9f2413512ca1ff519e10f7c8dd8915633c343ded6
4
+ data.tar.gz: dbbf6856befbbd129fb2ca9b684a755770c1d01ec4f5c81ea08b39f40a94f212
5
+ SHA512:
6
+ metadata.gz: a41d4cb2b9e40cfe0f7300aaff7e74c44fa0affa0509eebbcce0bfad09cfb73dabcfdf46087192cd3383962860d4c20b645b89bb7bdb5e4f3fb1854243c34141
7
+ data.tar.gz: 80119bbeb6bc9bfe99e98d47a7688e86ccb6595ace6e2d151dfd0eab4f8e5cab35f38de399d73020053aa2c0261977a7c866d0d627e50658fdfb22c7369a49ab
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.5.0] - 2026-10-26
4
+
5
+ - Initial release. Rails integration for Snapshot UI:
6
+ - Snapshot UI is configured automatically from the Rails application (root, tmp directory, mount path and host);
7
+ with the defaults no initializer is required.
8
+ - The URL shown after a test run is derived from `config.snapshot_ui.mount_path` and `config.snapshot_ui.host`
9
+ (the host defaults from the app's `default_url_options`, then `PORT`, then `http://localhost:3000`).
10
+ - `mount_snapshot_ui` mounts the web interface in `config/routes.rb`; the default path is `/rails/ui_snapshots`.
11
+ - `take_snapshot` is available in `ActionDispatch::IntegrationTest` without an explicit `include`.
12
+ - `take_snapshot` also captures emails (`ActionMailer::MessageDelivery` or `Mail::Message`) in mailer and
13
+ integration tests, so mailers can be reviewed in the browser instead of maintaining Action Mailer previews.
14
+ Both the HTML and plain-text parts are captured, with attachments and an `.eml` download.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Tomaz Zlender
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # Snapshot UI for Rails
2
+
3
+ Rails integration for [Snapshot UI](https://github.com/tomazzlender/snapshot_ui). Take snapshots of responses in your
4
+ integration tests and view them in a browser — with no configuration.
5
+
6
+ This gem wires Snapshot UI into a Rails application: it fills in Snapshot UI's configuration from what Rails already
7
+ knows, adds a router helper to mount the web interface, and makes `take_snapshot` available in your integration tests.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ group :development, :test do
14
+ gem "snapshot_ui-rails"
15
+ end
16
+ ```
17
+
18
+ ```sh
19
+ bundle install
20
+ ```
21
+
22
+ The `snapshot_ui` gem is pulled in automatically.
23
+
24
+ ## Usage
25
+
26
+ With the defaults, the whole setup is one line in your routes — **no initializer, no configuration**.
27
+
28
+ ### 1. Mount the web interface
29
+
30
+ Snapshot UI is a development tool: it displays artifacts captured during test runs and has nothing to show in
31
+ production. Mount it only where you need it, the way you would Sidekiq's or Flipper's dashboards:
32
+
33
+ ```ruby
34
+ # config/routes.rb
35
+ Rails.application.routes.draw do
36
+ mount_snapshot_ui if Rails.env.development?
37
+ # ...
38
+ end
39
+ ```
40
+
41
+ This mounts the UI at `/rails/ui_snapshots` — alongside Rails' own development tools such as `/rails/info` and
42
+ `/rails/mailers`. Every snapshot you take shows up there, grouped by the test that produced it:
43
+
44
+ ![The list of snapshots](docs/images/snapshots_index.png)
45
+
46
+ ### 2. Take snapshots in your integration tests
47
+
48
+ `take_snapshot` is available in every `ActionDispatch::IntegrationTest` — no `include` required:
49
+
50
+ ```ruby
51
+ require "test_helper"
52
+
53
+ class PlotsTest < ActionDispatch::IntegrationTest
54
+ test "shows a plot" do
55
+ get "/plots/A3"
56
+
57
+ take_snapshot(response)
58
+
59
+ assert_response :success
60
+ end
61
+ end
62
+ ```
63
+
64
+ Run the test with snapshots enabled and the response is captured and rendered exactly as it came back — here, a plot
65
+ page from a community-garden site:
66
+
67
+ ![A page response rendered as a snapshot](docs/images/response_snapshot.png)
68
+
69
+ `take_snapshot` accepts an object that responds to `#body` (such as the integration test's `response`) or a plain
70
+ `String`, and two options:
71
+
72
+ ```ruby
73
+ take_snapshot(response, title: "Plot detail", slug: "plot-A3")
74
+ ```
75
+
76
+ * `title` — the name shown in the list (defaults to a name derived from the test).
77
+ * `slug` — a custom, stable URL for the snapshot (`/rails/ui_snapshots/plot-A3`); must be unique.
78
+
79
+ ### 3. Snapshotting Action Mailer emails
80
+
81
+ `take_snapshot` also accepts an email, so you can review mailers in the browser instead of maintaining Action Mailer
82
+ previews:
83
+
84
+ ```ruby
85
+ class NotifierMailerTest < ActionMailer::TestCase
86
+ test "welcome email" do
87
+ email = NotifierMailer.welcome(user)
88
+ take_snapshot(email)
89
+
90
+ assert_emails 0 # your usual assertions are unaffected
91
+ end
92
+ end
93
+ ```
94
+
95
+ One call captures the whole email, and the snapshot renders with the same chrome as any Rails view — the subject,
96
+ mailer/action and address headers, attachments, and the rendered email itself:
97
+
98
+ ![An Action Mailer email rendered as a snapshot](docs/images/mail_snapshot_html.png)
99
+
100
+ Because the whole message is captured, **both the HTML and the plain-text versions are available** from the one call —
101
+ a switcher appears whenever the email has both, so there is nothing to keep in sync:
102
+
103
+ ![The same email viewed as plain text](docs/images/mail_snapshot_text.png)
104
+
105
+ The snapshot also offers each attachment as a download and a **Download .eml** of the raw message, and the full header
106
+ set is one click away under “All headers”.
107
+
108
+ `take_snapshot` accepts an `ActionMailer::MessageDelivery` (what a mailer action returns) or a `Mail::Message`, and
109
+ the same `title:` and `slug:` options as responses. It is available in `ActionMailer::TestCase` and in integration
110
+ tests with no explicit `include`.
111
+
112
+ ### 4. Run the tests and view the snapshots
113
+
114
+ Snapshots are only taken when you ask for them, via the `--take-snapshots` flag or `TAKE_SNAPSHOTS=1`:
115
+
116
+ ```sh
117
+ bin/rails test test/integration --take-snapshots
118
+ ```
119
+
120
+ At the end of the run the URL to review the snapshots is printed. Start your app (`bin/rails server`) and open
121
+ <http://localhost:3000/rails/ui_snapshots>. The page refreshes automatically as you take new snapshots.
122
+
123
+ You'll usually want the snapshots directory ignored by git:
124
+
125
+ ```
126
+ # .gitignore
127
+ /tmp/snapshot_ui/
128
+ ```
129
+
130
+ ## Configuration
131
+
132
+ **None is required.** Everything is inferred from the Rails application:
133
+
134
+ | Setting | Default |
135
+ | ------------------------ | ------------------------------------------------------------------------------- |
136
+ | `project_root_directory` | `Rails.root` |
137
+ | `storage_directory` | `Rails.root/tmp/snapshot_ui` |
138
+ | `mount_path` | `/rails/ui_snapshots` |
139
+ | `host` | the app's `default_url_options`, else `PORT`, else `http://localhost:3000` |
140
+
141
+ The mount path and host are only used to build the "ready for review" URL printed after a test run and the link on the
142
+ UI's empty state; the interface itself works wherever it is mounted.
143
+
144
+ To override, add an initializer — it runs after this gem's defaults, so your values win:
145
+
146
+ ```ruby
147
+ # config/initializers/snapshot_ui.rb
148
+ Rails.application.configure do
149
+ # Mount somewhere other than /rails/ui_snapshots (also change the argument to mount_snapshot_ui):
150
+ config.snapshot_ui.mount_path = "/admin/snapshots"
151
+
152
+ # Only needed if your development server isn't on http://localhost:3000:
153
+ config.snapshot_ui.host = "http://localhost:4000"
154
+ end
155
+ ```
156
+
157
+ ```ruby
158
+ # config/routes.rb — pass the same path when it isn't the default
159
+ mount_snapshot_ui at: "/admin/snapshots" if Rails.env.development?
160
+ ```
161
+
162
+ If you already set `config.action_controller.default_url_options`, the host is taken from there and you don't need to
163
+ set `config.snapshot_ui.host` at all.
164
+
165
+ ## How it works
166
+
167
+ The gem is a thin adapter over `snapshot_ui`. A Railtie:
168
+
169
+ * fills in `SnapshotUI.configuration` from `Rails.root`, the mount path and the resolved host;
170
+ * requires the `mount_snapshot_ui` router helper, which mounts the same `SnapshotUI::Web` Rack app the core gem ships;
171
+ * includes `SnapshotUI::Test::MinitestHelpers` into `ActionDispatch::IntegrationTest` via `ActiveSupport.on_load`.
172
+
173
+ For anything not specific to Rails, see the
174
+ [Snapshot UI README](https://github.com/tomazzlender/snapshot_ui/tree/main/gems/snapshot_ui).
175
+
176
+ ## License
177
+
178
+ MIT.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+
5
+ module SnapshotUI
6
+ module Rails
7
+ # Reads a stored "mail" snapshot back into a usable form: the reconstructed
8
+ # Mail::Message plus the parts and metadata the mail view needs.
9
+ class Mail
10
+ HTML = "text/html"
11
+ TEXT = "text/plain"
12
+
13
+ def initialize(snapshot)
14
+ @type_data = snapshot.type_data
15
+ end
16
+
17
+ def message
18
+ @message ||= begin
19
+ message = ::Mail::Message.new(@type_data[:message].to_s)
20
+ message.bcc = @type_data[:bcc] if @type_data[:bcc]
21
+ message
22
+ end
23
+ end
24
+
25
+ def mailer
26
+ @type_data[:mailer]
27
+ end
28
+
29
+ def action
30
+ @type_data[:action]
31
+ end
32
+
33
+ def html?
34
+ !part_for(HTML).nil?
35
+ end
36
+
37
+ def text?
38
+ !part_for(TEXT).nil?
39
+ end
40
+
41
+ # Both alternatives were captured — offer a switcher.
42
+ def multipart_alternatives?
43
+ html? && text?
44
+ end
45
+
46
+ def default_mime_type
47
+ if html?
48
+ HTML
49
+ else
50
+ (text? ? TEXT : message.mime_type)
51
+ end
52
+ end
53
+
54
+ # The decoded body for a MIME type, falling back to the message body for a
55
+ # single-part email whose type matches.
56
+ def part_body(mime_type)
57
+ if (part = part_for(mime_type))
58
+ part.respond_to?(:decoded) ? part.decoded : part.to_s
59
+ end
60
+ end
61
+
62
+ def attachments
63
+ message.attachments || []
64
+ end
65
+
66
+ def eml
67
+ message.to_s
68
+ end
69
+
70
+ private
71
+
72
+ def part_for(mime_type)
73
+ message.find_first_mime_type(mime_type) || ((message.mime_type == mime_type) ? message : nil)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snapshot_ui/snapshot"
4
+
5
+ module SnapshotUI
6
+ module Rails
7
+ # Captures an ActionMailer / Mail message as a snapshot.
8
+ #
9
+ # The whole email is serialized to its RFC-822 form (which keeps every MIME
10
+ # part — both the HTML and the plain-text alternatives, and any attachments),
11
+ # so a single stored string round-trips to both views. BCC is stored
12
+ # separately because it is not part of the serialized message.
13
+ module MailSnapshot
14
+ TYPE = "mail"
15
+
16
+ module_function
17
+
18
+ def persist(mail, context:)
19
+ message = to_mail_message(mail)
20
+
21
+ SnapshotUI::Snapshot.persist(
22
+ snapshotee: nil,
23
+ context: context,
24
+ type: TYPE,
25
+ type_data: {
26
+ message: message.to_s,
27
+ bcc: Array(message.bcc),
28
+ mailer: mailer_name(mail),
29
+ action: action_name(mail)
30
+ }
31
+ )
32
+ end
33
+
34
+ # ActionMailer hands tests a MessageDelivery; #message is the underlying
35
+ # Mail::Message. A bare Mail::Message is accepted too.
36
+ def to_mail_message(mail)
37
+ mail.respond_to?(:message) ? mail.message : mail
38
+ end
39
+
40
+ # The mailer class name. ActionMailer::MessageDelivery stores it as
41
+ # @mailer_class; #mailer_class is unreliable across Rails versions (on some
42
+ # it returns MessageDelivery itself), so prefer the ivar.
43
+ def mailer_name(mail)
44
+ mailer_class =
45
+ if mail.instance_variable_defined?(:@mailer_class)
46
+ mail.instance_variable_get(:@mailer_class)
47
+ elsif mail.respond_to?(:mailer_class)
48
+ mail.mailer_class
49
+ end
50
+
51
+ (mailer_class || mail.class).name
52
+ end
53
+
54
+ # ActionMailer::MessageDelivery stores the action as @action (a Symbol);
55
+ # some versions/objects expose #action_name. Fall back gracefully.
56
+ def action_name(mail)
57
+ if mail.respond_to?(:action_name)
58
+ mail.action_name
59
+ elsif mail.instance_variable_defined?(:@action)
60
+ mail.instance_variable_get(:@action)&.to_s
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require "snapshot_ui"
5
+
6
+ module SnapshotUI
7
+ module Rails
8
+ # Wires Snapshot UI into a Rails application:
9
+ #
10
+ # * fills in the configuration Snapshot UI needs from what Rails already
11
+ # knows (its root, its tmp directory and the mount path), so with the
12
+ # defaults no initializer is required;
13
+ # * adds +mount_snapshot_ui+ to the router;
14
+ # * makes +take_snapshot+ available in integration tests.
15
+ #
16
+ # To override anything, set it under +config.snapshot_ui+ in
17
+ # +config/initializers/snapshot_ui.rb+ (or an environment file):
18
+ #
19
+ # Rails.application.configure do
20
+ # config.snapshot_ui.mount_path = "/admin/snapshots"
21
+ # config.snapshot_ui.host = "http://localhost:4000"
22
+ # end
23
+ class Railtie < ::Rails::Railtie
24
+ config.snapshot_ui = ActiveSupport::OrderedOptions.new
25
+ # The path the UI is mounted at.
26
+ config.snapshot_ui.mount_path = nil
27
+ # The scheme://host[:port] the UI is reachable at, for the "ready for
28
+ # review" message. Defaults to the application's declared host, else
29
+ # http://localhost:3000. See SnapshotUI::Rails.default_host.
30
+ config.snapshot_ui.host = nil
31
+ # A full URL that overrides both of the above for the printed message.
32
+ # Rarely needed; prefer mount_path + host.
33
+ config.snapshot_ui.web_url = nil
34
+
35
+ # The mount path is known immediately, so the router helper and the
36
+ # storage paths can use it.
37
+ initializer "snapshot_ui.configure" do |app|
38
+ SnapshotUI::Rails.mount_path = app.config.snapshot_ui.mount_path if app.config.snapshot_ui.mount_path
39
+
40
+ SnapshotUI.configure do |config|
41
+ config.project_root_directory ||= ::Rails.root.to_s
42
+ config.storage_directory ||= ::Rails.root.join("tmp", "snapshot_ui").to_s
43
+ end
44
+ end
45
+
46
+ # The web URL is resolved after initialization so that a host declared in
47
+ # a user initializer (config.action_controller.default_url_options, or
48
+ # config.snapshot_ui.host) has already been set.
49
+ config.after_initialize do |app|
50
+ options = app.config.snapshot_ui
51
+
52
+ SnapshotUI.configuration.web_url =
53
+ options.web_url ||
54
+ SnapshotUI::Rails.web_url(host: options.host || SnapshotUI::Rails.default_host(app))
55
+ end
56
+
57
+ # The +mount_snapshot_ui+ router helper.
58
+ initializer "snapshot_ui.routes" do
59
+ require_relative "routes"
60
+ end
61
+
62
+ # Teach the web interface to render "mail" snapshots.
63
+ initializer "snapshot_ui.mail_renderer" do
64
+ require "snapshot_ui/web"
65
+ require_relative "mail_snapshot"
66
+ require_relative "web/mail_renderer"
67
+ SnapshotUI::Web.register_renderer(
68
+ SnapshotUI::Rails::MailSnapshot::TYPE,
69
+ SnapshotUI::Rails::Web::MailRenderer
70
+ )
71
+ end
72
+
73
+ # +take_snapshot+ in tests, without an explicit include. One helper covers
74
+ # both integration tests (responses) and mailer tests (emails).
75
+ initializer "snapshot_ui.test_helpers" do
76
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
77
+ require_relative "test/helpers"
78
+ include SnapshotUI::Rails::Test::Helpers
79
+ end
80
+
81
+ ActiveSupport.on_load(:action_mailer_test_case) do
82
+ require_relative "test/helpers"
83
+ include SnapshotUI::Rails::Test::Helpers
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snapshot_ui/web"
4
+
5
+ module ActionDispatch
6
+ module Routing
7
+ class Mapper
8
+ # Mounts the Snapshot UI web interface.
9
+ #
10
+ # # config/routes.rb
11
+ # mount_snapshot_ui
12
+ # mount_snapshot_ui at: "/admin/snapshots"
13
+ #
14
+ # With no argument it mounts at +config.snapshot_ui.mount_path+
15
+ # (default +/rails/ui_snapshots+).
16
+ def mount_snapshot_ui(at: SnapshotUI::Rails.mount_path)
17
+ mount SnapshotUI::Web, at: at, as: "snapshot_ui"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snapshot_ui"
4
+ require_relative "../mail_snapshot"
5
+
6
+ module SnapshotUI
7
+ module Rails
8
+ module Test
9
+ # A single +take_snapshot+ for Rails tests that dispatches by what it is
10
+ # given: an email (ActionMailer::MessageDelivery or Mail::Message) is
11
+ # captured as a mail snapshot; anything else (a response, or a String of
12
+ # HTML) is captured as a response snapshot.
13
+ #
14
+ # Included into both ActionDispatch::IntegrationTest and
15
+ # ActionMailer::TestCase by the railtie, so one method covers both.
16
+ module Helpers
17
+ def take_snapshot(snapshotee, title: nil, slug: nil)
18
+ return unless SnapshotUI.snapshot_taking_enabled?
19
+
20
+ type, type_data = classify(snapshotee)
21
+ SnapshotUI.exit_if_not_configured!
22
+ increment_take_snapshot_counter_scoped_by_test
23
+
24
+ context = {
25
+ test_framework: "minitest",
26
+ method_name: name,
27
+ source_location: build_source_location(caller_locations(1..1).first),
28
+ test_case_name: self.class.to_s,
29
+ take_snapshot_index: _take_snapshot_counter - 1,
30
+ metadata: {title: title, slug: slug}
31
+ }
32
+
33
+ if type == SnapshotUI::Rails::MailSnapshot::TYPE
34
+ SnapshotUI::Rails::MailSnapshot.persist(snapshotee, context: context)
35
+ else
36
+ SnapshotUI::Snapshot.persist(snapshotee: snapshotee, context: context, type: type, type_data: type_data)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # Returns [type, type_data]. type_data is nil for responses (the core
43
+ # extracts it from the snapshotee).
44
+ def classify(snapshotee)
45
+ if mail_like?(snapshotee)
46
+ [SnapshotUI::Rails::MailSnapshot::TYPE, nil]
47
+ elsif snapshotee.respond_to?(:body) || snapshotee.is_a?(String)
48
+ [SnapshotUI::Snapshot::DEFAULT_TYPE, nil]
49
+ else
50
+ raise ArgumentError,
51
+ "#take_snapshot accepts a response (something responding to #body), a String of HTML, " \
52
+ "or an email (ActionMailer::MessageDelivery / Mail::Message). You provided `#{snapshotee.class}`."
53
+ end
54
+ end
55
+
56
+ def mail_like?(object)
57
+ (defined?(ActionMailer::MessageDelivery) && object.is_a?(ActionMailer::MessageDelivery)) ||
58
+ (defined?(::Mail::Message) && object.is_a?(::Mail::Message))
59
+ end
60
+
61
+ attr_reader :_take_snapshot_counter
62
+
63
+ def increment_take_snapshot_counter_scoped_by_test
64
+ @_take_snapshot_counter ||= 0
65
+ @_take_snapshot_counter += 1
66
+ end
67
+
68
+ def build_source_location(caller_location)
69
+ [caller_location.path, caller_location.lineno]
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snapshot_ui/version"
4
+
5
+ module SnapshotUI
6
+ module Rails
7
+ VERSION = SnapshotUI::VERSION
8
+ end
9
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "rack/utils"
5
+ require_relative "../mail"
6
+
7
+ module SnapshotUI
8
+ module Rails
9
+ module Web
10
+ # Renders "mail" snapshots for SnapshotUI::Web. Registered by the railtie.
11
+ #
12
+ # #show renders the email chrome (headers, an HTML/plain-text switcher when
13
+ # both parts exist, attachments, an .eml download) with the chosen part
14
+ # shown in an iframe. #raw serves a single part with its content type, or
15
+ # the .eml file.
16
+ module MailRenderer
17
+ VIEW = "snapshots/mail/show"
18
+
19
+ module_function
20
+
21
+ def show(snapshot, view)
22
+ mail = SnapshotUI::Rails::Mail.new(snapshot)
23
+ part = requested_part(view) || mail.default_mime_type
24
+
25
+ presenter = Presenter.new(snapshot: snapshot, mail: mail, part: part, view: view)
26
+ view.render_body_in_layout(presenter.render_body, status: 200)
27
+ end
28
+
29
+ def raw(snapshot, view)
30
+ mail = SnapshotUI::Rails::Mail.new(snapshot)
31
+
32
+ if view.request.params["eml"]
33
+ filename = [mail.mailer, mail.action].compact.join("#")
34
+ filename = "email" if filename.empty?
35
+ return [200,
36
+ {"content-type" => "message/rfc822",
37
+ "content-disposition" => %(attachment; filename="#{filename}.eml")},
38
+ [mail.eml]]
39
+ end
40
+
41
+ mime_type = view.request.params["part"] || mail.default_mime_type
42
+ body = mail.part_body(mime_type)
43
+
44
+ if body
45
+ [200, {"content-type" => "#{mime_type}; charset=utf-8"}, [body]]
46
+ else
47
+ [404, {"content-type" => "text/plain; charset=utf-8"},
48
+ ["Email part `#{mime_type}` not found in this snapshot."]]
49
+ end
50
+ end
51
+
52
+ def requested_part(view)
53
+ mime = view.request.params["part"]
54
+ mime if [SnapshotUI::Rails::Mail::HTML, SnapshotUI::Rails::Mail::TEXT].include?(mime)
55
+ end
56
+
57
+ # Passed as the render context so the template can call these helpers and
58
+ # still reach the core view's URL/asset helpers via method_missing.
59
+ class Presenter
60
+ H = Rack::Utils
61
+
62
+ def initialize(snapshot:, mail:, part:, view:)
63
+ @snapshot = snapshot
64
+ @mail = mail
65
+ @part = part
66
+ @view = view
67
+ end
68
+
69
+ attr_reader :snapshot, :mail, :part, :view
70
+
71
+ # Renders this gem's mail template to a <body> string. The template
72
+ # lives under this file's directory (web/views/...).
73
+ def render_body
74
+ template = File.expand_path("views/#{MailRenderer::VIEW}.html.erb", __dir__)
75
+ ERB.new(File.read(template), trim_mode: "-").result(binding)
76
+ end
77
+
78
+ def h(text)
79
+ H.escape_html(text.to_s)
80
+ end
81
+
82
+ def raw_part_url(mime_type)
83
+ base = view.raw_snapshot_path(snapshot.slug)
84
+ "#{base}?#{H.build_query("part" => mime_type)}"
85
+ end
86
+
87
+ def eml_url
88
+ "#{view.raw_snapshot_path(snapshot.slug)}?#{H.build_query("eml" => "1")}"
89
+ end
90
+
91
+ def show_url(mime_type)
92
+ "#{view.snapshot_path(snapshot.slug)}?#{H.build_query("part" => mime_type)}"
93
+ end
94
+
95
+ def attachment_data_uri(attachment)
96
+ "data:application/octet-stream;base64,#{Base64.strict_encode64(attachment.decoded)}"
97
+ end
98
+
99
+ def attachment_filename(attachment)
100
+ attachment.respond_to?(:original_filename) ? attachment.original_filename : attachment.filename
101
+ end
102
+
103
+ # Let the template reach the core Application helpers (root_path, etc.).
104
+ def method_missing(name, *args, &block)
105
+ view.respond_to?(name, true) ? view.send(name, *args, &block) : super
106
+ end
107
+
108
+ def respond_to_missing?(name, include_private = false)
109
+ view.respond_to?(name, include_private) || super
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,56 @@
1
+ <body id="snapshots_mail" <%= refresh_controller %>>
2
+ <div class="mail">
3
+ <header class="mail_header">
4
+ <a class="back" href="<%= root_path %>">&larr; All snapshots</a>
5
+ <h1><%= h(mail.message.subject) %></h1>
6
+ <% if mail.mailer || mail.action %>
7
+ <p class="mailer"><%= h([mail.mailer, mail.action].compact.join("#")) %></p>
8
+ <% end %>
9
+
10
+ <dl class="fields">
11
+ <dt>From</dt><dd><%= h(mail.message.header["from"]) %></dd>
12
+ <dt>To</dt><dd><%= h(mail.message.header["to"]) %></dd>
13
+ <% if mail.message.cc %><dt>Cc</dt><dd><%= h(mail.message.header["cc"]) %></dd><% end %>
14
+ <% if mail.message.bcc && !Array(mail.message.bcc).empty? %><dt>Bcc</dt><dd><%= h(Array(mail.message.bcc).join(", ")) %></dd><% end %>
15
+ <% if mail.message.reply_to %><dt>Reply-To</dt><dd><%= h(mail.message.header["reply-to"]) %></dd><% end %>
16
+ </dl>
17
+
18
+ <div class="mail_toolbar">
19
+ <% if mail.multipart_alternatives? %>
20
+ <div class="parts">
21
+ <a class="part_tab <%= "is_active" if part == SnapshotUI::Rails::Mail::HTML %>" href="<%= show_url(SnapshotUI::Rails::Mail::HTML) %>">HTML</a>
22
+ <a class="part_tab <%= "is_active" if part == SnapshotUI::Rails::Mail::TEXT %>" href="<%= show_url(SnapshotUI::Rails::Mail::TEXT) %>">Plain text</a>
23
+ </div>
24
+ <% else %>
25
+ <span class="part_single"><%= part == SnapshotUI::Rails::Mail::HTML ? "HTML email" : "Plain-text email" %></span>
26
+ <% end %>
27
+
28
+ <a class="eml" href="<%= eml_url %>">Download .eml</a>
29
+ </div>
30
+
31
+ <% unless mail.attachments.empty? %>
32
+ <div class="attachments">
33
+ <span class="attachments_label">Attachments:</span>
34
+ <% mail.attachments.each do |attachment| %>
35
+ <a class="attachment" href="<%= attachment_data_uri(attachment) %>" download="<%= h(attachment_filename(attachment)) %>"><%= h(attachment_filename(attachment)) %></a>
36
+ <% end %>
37
+ </div>
38
+ <% end %>
39
+
40
+ <details class="all_headers">
41
+ <summary>All headers</summary>
42
+ <table>
43
+ <% mail.message.header_fields.each do |field| %>
44
+ <tr><td class="name"><%= h(field.name) %></td><td><%= h(field.value) %></td></tr>
45
+ <% end %>
46
+ </table>
47
+ </details>
48
+ </header>
49
+
50
+ <% if mail.part_body(part) %>
51
+ <iframe id="raw" class="mail_body <%= "is_text" if part == SnapshotUI::Rails::Mail::TEXT %>" src="<%= raw_part_url(part) %>"></iframe>
52
+ <% else %>
53
+ <p class="empty">This email has no <%= part == SnapshotUI::Rails::Mail::HTML ? "HTML" : "plain-text" %> part.</p>
54
+ <% end %>
55
+ </div>
56
+ </body>
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snapshot_ui"
4
+ require_relative "rails/version"
5
+
6
+ module SnapshotUI
7
+ # Rails integration for Snapshot UI. Requiring this file (which Bundler does
8
+ # automatically for the +snapshot_ui-rails+ gem) loads the railtie that
9
+ # configures Snapshot UI from the Rails application and adds the router and
10
+ # test helpers.
11
+ #
12
+ # With the defaults no configuration is needed. To override, set values under
13
+ # +config.snapshot_ui+ (see the README).
14
+ module Rails
15
+ DEFAULT_MOUNT_PATH = "/rails/ui_snapshots"
16
+ DEFAULT_HOST = "http://localhost:3000"
17
+
18
+ class << self
19
+ # The path the UI is mounted at. Set by the railtie from
20
+ # +config.snapshot_ui.mount_path+; used to build the default web URL and
21
+ # by +mount_snapshot_ui+.
22
+ attr_writer :mount_path
23
+
24
+ def mount_path
25
+ @mount_path || DEFAULT_MOUNT_PATH
26
+ end
27
+
28
+ # Builds the URL the "ready for review" message and the empty-state link
29
+ # point at, from a host (scheme://host:port) and the mount path.
30
+ def web_url(host:, mount_path: self.mount_path)
31
+ "#{host.to_s.chomp("/")}#{mount_path}"
32
+ end
33
+
34
+ # The host (scheme://host[:port]) to use when the application hasn't been
35
+ # told one explicitly. Rails does not know the address its development
36
+ # server will bind to, so this is a best effort: the host declared in
37
+ # +config.action_controller.default_url_options+ if present, then the PORT
38
+ # environment variable, then the conventional http://localhost:3000.
39
+ def default_host(app)
40
+ options = default_url_options(app)
41
+ return DEFAULT_HOST if options.empty? && !ENV.key?("PORT")
42
+
43
+ scheme = url_scheme(app, options)
44
+ host = options[:host] || "localhost"
45
+ port = options[:port] || ENV["PORT"] || 3000
46
+
47
+ "#{scheme}://#{host}:#{port}"
48
+ end
49
+
50
+ private
51
+
52
+ def default_url_options(app)
53
+ app.config.action_controller.default_url_options || {}
54
+ rescue
55
+ {}
56
+ end
57
+
58
+ def url_scheme(app, options)
59
+ scheme = options[:protocol] || (app.config.force_ssl ? "https" : "http")
60
+ scheme.to_s.sub(%r{://\z}, "")
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ require_relative "rails/railtie"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is named "snapshot_ui-rails"; its code lives under SnapshotUI::Rails.
4
+ require_relative "snapshot_ui/rails"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../snapshot_ui/lib/snapshot_ui/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "snapshot_ui-rails"
7
+ spec.version = SnapshotUI::VERSION
8
+ spec.authors = ["Tomaz Zlender"]
9
+ spec.email = ["tomaz@zlender.se"]
10
+
11
+ spec.summary = "Rails integration for Snapshot UI — take snapshots of responses in tests and view them in a browser."
12
+ spec.homepage = "https://github.com/tomazzlender/snapshot_ui"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.3.0"
15
+
16
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main/gems/snapshot_ui-rails"
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/gems/snapshot_ui-rails/CHANGELOG.md"
20
+
21
+ gem_root = File.expand_path(__dir__)
22
+ spec.files = Dir.chdir(gem_root) do
23
+ Dir.glob(%w[lib/**/* snapshot_ui-rails.gemspec README.md CHANGELOG.md LICENSE.txt]).select { |path| File.file?(path) }
24
+ end
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "snapshot_ui", SnapshotUI::VERSION
28
+ spec.add_dependency "railties", ">= 7.1", "< 9"
29
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: snapshot_ui-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Tomaz Zlender
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: snapshot_ui
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.5.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.5.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '9'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '7.1'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '9'
46
+ email:
47
+ - tomaz@zlender.se
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - CHANGELOG.md
53
+ - LICENSE.txt
54
+ - README.md
55
+ - lib/snapshot_ui-rails.rb
56
+ - lib/snapshot_ui/rails.rb
57
+ - lib/snapshot_ui/rails/mail.rb
58
+ - lib/snapshot_ui/rails/mail_snapshot.rb
59
+ - lib/snapshot_ui/rails/railtie.rb
60
+ - lib/snapshot_ui/rails/routes.rb
61
+ - lib/snapshot_ui/rails/test/helpers.rb
62
+ - lib/snapshot_ui/rails/version.rb
63
+ - lib/snapshot_ui/rails/web/mail_renderer.rb
64
+ - lib/snapshot_ui/rails/web/views/snapshots/mail/show.html.erb
65
+ - snapshot_ui-rails.gemspec
66
+ homepage: https://github.com/tomazzlender/snapshot_ui
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ allowed_push_host: https://rubygems.org
71
+ homepage_uri: https://github.com/tomazzlender/snapshot_ui
72
+ source_code_uri: https://github.com/tomazzlender/snapshot_ui/tree/main/gems/snapshot_ui-rails
73
+ changelog_uri: https://github.com/tomazzlender/snapshot_ui/blob/main/gems/snapshot_ui-rails/CHANGELOG.md
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.3.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.6.9
89
+ specification_version: 4
90
+ summary: Rails integration for Snapshot UI — take snapshots of responses in tests
91
+ and view them in a browser.
92
+ test_files: []