snapshot_ui 0.4.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 +28 -0
  3. data/README.md +145 -4
  4. data/lib/minitest/snapshot_ui_plugin.rb +9 -0
  5. data/lib/snapshot_ui/colorize.rb +15 -4
  6. data/lib/snapshot_ui/configuration.rb +12 -3
  7. data/lib/snapshot_ui/snapshot/context.rb +4 -1
  8. data/lib/snapshot_ui/snapshot/storage.rb +54 -3
  9. data/lib/snapshot_ui/snapshot.rb +54 -13
  10. data/lib/snapshot_ui/test/minitest_helpers.rb +21 -6
  11. data/lib/snapshot_ui/version.rb +1 -1
  12. data/lib/snapshot_ui/web/application.rb +100 -39
  13. data/lib/snapshot_ui/web/assets/javascripts/application.js +4 -2
  14. data/lib/snapshot_ui/web/assets/javascripts/controllers/refresh_controller.js +76 -12
  15. data/lib/snapshot_ui/web/assets/javascripts/controllers/source_location_controller.js +14 -6
  16. data/lib/snapshot_ui/web/assets/javascripts/vendor/stimulus.js +2564 -0
  17. data/lib/snapshot_ui/web/assets/javascripts/vendor/turbo.js +7257 -0
  18. data/lib/snapshot_ui/web/assets/stylesheets/application.css +120 -0
  19. data/lib/snapshot_ui/web/views/layout.html.erb +4 -9
  20. data/lib/snapshot_ui/web/views/snapshots/index.html.erb +5 -11
  21. data/lib/snapshot_ui/web.rb +27 -10
  22. data/lib/snapshot_ui.rb +4 -2
  23. data/snapshot_ui.gemspec +8 -14
  24. metadata +17 -76
  25. data/bin/snapshot_ui +0 -7
  26. data/lib/snapshot_ui/cli/watcher.ru +0 -91
  27. data/lib/snapshot_ui/cli.rb +0 -29
  28. data/lib/snapshot_ui/web/assets/javascripts/channels/consumer.js +0 -3
  29. data/lib/snapshot_ui/web/assets/javascripts/channels/index.js +0 -1
  30. data/lib/snapshot_ui/web/assets/javascripts/channels/refresh_channel.js +0 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c7a544dede4a2b9a765c9c5ddab01844a53dbdaa8d24dd833cd8debfe9c6beb
4
- data.tar.gz: 981157f9c9de83549c752d64df8c6b1b60abe963b3341ee9365baf95b4910d92
3
+ metadata.gz: 69cf45167a5e8d9a8bd93ad9494521ef09da395e5224471de4023b93c926ede0
4
+ data.tar.gz: 75866a6374d237f1a526b15f563d287a713e6c6b8fa5f619fe811190f08c7ae5
5
5
  SHA512:
6
- metadata.gz: c25b8d6740c9ad9efb4f856c67911056a71d8168e705db45a9227ec60e70ce4ee1cc01418e12a40aa15d8e89af734b15b3cdd50309e96ae40511c5c790f62c5e
7
- data.tar.gz: e06a789dea17d12e9ac8f89f091f69dcbe2e664061ea364659043caeb0aa6c86561772be3ce4587e5f5e4be90a38f4b9ddb1b9cd4df521be6d315d94a0284930
6
+ metadata.gz: 954c2b1b6bc354e3d36e2bfe91237969ba7f3c3f0a7587681ad59783d38a6199d581a853cfbd33855e2180e3b7039b1ac81349d80ef52aed63ed21d749b8ad2f
7
+ data.tar.gz: 5681185428385eba65beefd0647b205b7ac2ce40bb67b1cf17a04764f086529db0af68df33c614152f416593da66f9157610988371e1ad13e3b9a93604187ec3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-10-26
4
+
5
+ - Snapshots now carry a type. "response" (the default) is unchanged; other gems can register additional types
6
+ and renderers (snapshot_ui-rails adds "mail"). Snapshots written before this are read as responses.
7
+
8
+ - Live updates no longer need the `snapshot_ui live` command or a separate WebSocket server. The web UI polls a new
9
+ `version` endpoint (`<mount path>/version`, e.g. `/ui/snapshots/version`) once a second and refreshes the page
10
+ through Turbo when the published snapshots change.
11
+ - Removed the `snapshot_ui` executable. Setting `config.live_websocket_url` now prints a deprecation warning and has
12
+ no effect.
13
+ - Dropped the `falcon`, `async-websocket`, `listen` and `thor` dependencies, as well as the ActionCable JavaScript.
14
+ - Turbo and Stimulus are now bundled with the gem instead of being loaded from a CDN, so the web UI works offline.
15
+ - Publishing snapshots moves the previous ones aside instead of deleting them first, so the list of snapshots is never
16
+ briefly empty while a test run finishes.
17
+ - Fixed `NoMethodError: undefined method 'rmtree'` on Ruby 4.0 by requiring `pathname` explicitly.
18
+ - Works with minitest 6, which no longer discovers plugins on its own: requiring `snapshot_ui/test/minitest_helpers`
19
+ now registers the minitest plugin, so `--take-snapshots` and publishing at the end of a run keep working
20
+ (`Minitest.load :snapshot_ui` works as well).
21
+ - Development dependencies updated; CI runs on Ruby 3.3 to 4.0 with both minitest 5 and 6.
22
+ - Requires Ruby 3.3 or newer.
23
+ - Works with Rack 2.2 as well as Rack 3, so it can be used in Rails 6.1 and 7.0 applications.
24
+ - Terminal colours are only used when writing to a terminal, and not when `NO_COLOR` is set.
25
+ - Snapshot files no longer record the class of the object passed to `take_snapshot`; it was never used.
26
+ - `rake vendor:update` downloads the latest Turbo and Stimulus into the gem.
27
+ - Pages for snapshots that can't be found respond with 404 instead of 200.
28
+ - The Rack application serving the UI is built once instead of on every request.
29
+ - A malformed snapshot file, or a URL whose slug would point outside the snapshots directory, now renders the not-found page instead of raising a 500 or reading an arbitrary file.
30
+
3
31
  ## [0.1.0] - 2024-06-09
4
32
 
5
33
  - Initial release
data/README.md CHANGED
@@ -1,8 +1,149 @@
1
1
  # Snapshot UI
2
2
 
3
- Take snapshots of responses in integration tests, and display them in a browser.
3
+ Take snapshots of responses in your integration tests and view them in a browser.
4
4
 
5
- Works with any kind of Rack application and minitest testing framework.
5
+ When a test makes a request, add `take_snapshot` and Snapshot UI saves the response. Run your tests with snapshots
6
+ enabled, then open the web interface to see exactly what each response rendered — useful for reviewing pages, emails,
7
+ or API payloads produced during testing. Snapshots are only captured when you ask for them, so normal test runs are
8
+ unaffected.
6
9
 
7
- > ℹ️ The Snapshot UI is the next generation of a similar library, [Snapshot Inspector](https://github.com/tomazzlender/snapshot_inspector).
8
- > It works with any kind of Rack application, not just Rails applications. Future development will take place here, and Snapshot Inspector will be eventually archived.
10
+ Works with any Rack application and the minitest testing framework.
11
+
12
+ **Using Rails?** The [`snapshot_ui-rails`](https://github.com/tomazzlender/snapshot_ui/tree/main/gems/snapshot_ui-rails)
13
+ gem wires all of this into Rails so it works out of the
14
+ box — no configuration, a `mount_snapshot_ui` route helper, and `take_snapshot` available in integration tests. If
15
+ you're on Rails, read that gem's README instead; the rest of this one describes the plain-Rack setup it builds on.
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ group :development, :test do
22
+ gem "snapshot_ui"
23
+ end
24
+ ```
25
+
26
+ ```sh
27
+ bundle install
28
+ ```
29
+
30
+ ## Setup
31
+
32
+ ### 1. Configure
33
+
34
+ Tell Snapshot UI where your project lives, where to store snapshots, and the URL the web interface is served at:
35
+
36
+ ```ruby
37
+ # e.g. config/snapshot_ui.rb, loaded by your app and your tests
38
+ require "snapshot_ui"
39
+
40
+ SnapshotUI.configure do |config|
41
+ config.storage_directory = File.expand_path("tmp/snapshot_ui", __dir__)
42
+ config.project_root_directory = File.expand_path(".", __dir__)
43
+ config.web_url = "http://localhost:3000/ui/snapshots"
44
+ end
45
+ ```
46
+
47
+ | Setting | What it is |
48
+ | ------------------------ | -------------------------------------------------------------------------- |
49
+ | `storage_directory` | Where snapshot files are written (put it under `tmp`). |
50
+ | `project_root_directory` | Your project root; used to build a snapshot's default name from its test. |
51
+ | `web_url` | The URL the interface is mounted at; shown after a run and on the UI. |
52
+
53
+ You'll usually want the storage directory ignored by git:
54
+
55
+ ```
56
+ # .gitignore
57
+ /tmp/snapshot_ui/
58
+ ```
59
+
60
+ ### 2. Mount the web interface
61
+
62
+ `SnapshotUI::Web` is a Rack application — mount it wherever your `web_url` points:
63
+
64
+ ```ruby
65
+ # config.ru
66
+ require "snapshot_ui/web"
67
+ require_relative "config/snapshot_ui"
68
+
69
+ map "/ui/snapshots" do
70
+ run SnapshotUI::Web
71
+ end
72
+ ```
73
+
74
+ The interface only displays snapshots captured during testing, so mount it where you view them (typically a local
75
+ development server), not in production.
76
+
77
+ ### 3. Take snapshots in your tests
78
+
79
+ Include the minitest helpers and call `take_snapshot` once a response is available:
80
+
81
+ ```ruby
82
+ require "minitest/autorun"
83
+ require "rack/test"
84
+ require "snapshot_ui/test/minitest_helpers"
85
+
86
+ class ApplicationTest < Minitest::Spec
87
+ include Rack::Test::Methods
88
+ include SnapshotUI::Test::MinitestHelpers
89
+
90
+ def app
91
+ @app ||= Application.new
92
+ end
93
+
94
+ it "renders the root page" do
95
+ get "/"
96
+
97
+ take_snapshot(last_response)
98
+
99
+ _(last_response.body).must_match("Hello")
100
+ end
101
+ end
102
+ ```
103
+
104
+ ## Taking snapshots
105
+
106
+ ```ruby
107
+ take_snapshot(snapshotee, title: nil, slug: nil)
108
+ ```
109
+
110
+ * `snapshotee` — an object that responds to `#body` (such as `last_response`) or a plain `String` of HTML.
111
+ * `title` — the name shown in the list. Defaults to a name derived from the test.
112
+ * `slug` — a custom, stable URL for the snapshot (`/ui/snapshots/<slug>`); must be unique. Defaults to one derived
113
+ from where the snapshot was taken.
114
+
115
+ You can take several snapshots in one test. `take_snapshot` does nothing unless snapshot-taking is enabled (see below),
116
+ so it's safe to leave in your tests.
117
+
118
+ ## Running the tests and viewing snapshots
119
+
120
+ Snapshots are captured only when you enable them, via the minitest flag or an environment variable:
121
+
122
+ ```sh
123
+ ruby -Itest test/application_test.rb --take-snapshots
124
+ # or
125
+ TAKE_SNAPSHOTS=1 rake test
126
+ ```
127
+
128
+ At the end of the run the URL to review the snapshots is printed. Start your application and open that URL (e.g.
129
+ <http://localhost:3000/ui/snapshots>).
130
+
131
+ The page refreshes itself as snapshots change: it polls a lightweight version endpoint and updates when a new test run
132
+ publishes snapshots — no page reload, and no extra process or server to run. Everything the interface needs (styles
133
+ and scripts) is served by the app itself, so it works offline.
134
+
135
+ ## How it works
136
+
137
+ * `take_snapshot` records the response body under `storage_directory` while your tests run.
138
+ * A minitest plugin (loaded when you require the helpers) publishes the run's snapshots at the end and prints the
139
+ review URL.
140
+ * `SnapshotUI::Web` renders the list and each snapshot, and exposes a version endpoint the page polls for live updates.
141
+
142
+ ## Requirements
143
+
144
+ * Ruby 3.3 or newer
145
+ * Rack 2.2, or Rack 3
146
+
147
+ ## License
148
+
149
+ Released under the [MIT License](LICENSE.txt).
@@ -4,6 +4,8 @@ require "minitest"
4
4
  module Minitest
5
5
  class << self
6
6
  def plugin_snapshot_ui_options(opts, _options)
7
+ return if opts.top.long.key?("take-snapshots")
8
+
7
9
  opts.on "--take-snapshots", "Take UI snapshots" do
8
10
  ENV["TAKE_SNAPSHOTS"] = "true"
9
11
  end
@@ -11,6 +13,9 @@ module Minitest
11
13
 
12
14
  def plugin_snapshot_ui_init(_options)
13
15
  return unless SnapshotUI.snapshot_taking_enabled?
16
+ # Minitest may list this plugin twice: registered below and discovered on its
17
+ # own (minitest 5) or loaded with `Minitest.load :snapshot_ui`. Set up once.
18
+ return if reporter.reporters.any? { |other| other.is_a?(SnapshotUIReporter) }
14
19
 
15
20
  SnapshotUI.exit_if_not_configured!
16
21
 
@@ -28,3 +33,7 @@ module Minitest
28
33
  end
29
34
  end
30
35
  end
36
+
37
+ # Minitest 6 no longer discovers plugins on its own: requiring this file, which
38
+ # `snapshot_ui/test/minitest_helpers` does, is what opts in.
39
+ Minitest.register_plugin :snapshot_ui if Minitest.respond_to?(:register_plugin)
@@ -1,15 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SnapshotUI
4
+ # Colours terminal output, unless the output isn't a terminal or NO_COLOR is set.
4
5
  module Colorize
5
6
  module_function
6
7
 
7
- def red(string)
8
- "\e[31m#{string}\e[0m"
8
+ def red(string, io: $stdout)
9
+ colorize(string, 31, io: io)
9
10
  end
10
11
 
11
- def green(string)
12
- "\e[32m#{string}\e[0m"
12
+ def green(string, io: $stdout)
13
+ colorize(string, 32, io: io)
14
+ end
15
+
16
+ def colorize(string, code, io: $stdout)
17
+ return string unless enabled?(io)
18
+
19
+ "\e[#{code}m#{string}\e[0m"
20
+ end
21
+
22
+ def enabled?(io = $stdout)
23
+ io.respond_to?(:tty?) && io.tty? && ENV["NO_COLOR"].to_s.empty?
13
24
  end
14
25
  end
15
26
  end
@@ -5,13 +5,12 @@ require_relative "colorize"
5
5
  module SnapshotUI
6
6
  class Configuration
7
7
  attr_writer :storage_directory, :project_root_directory
8
- attr_accessor :web_url, :live_websocket_url
8
+ attr_accessor :web_url
9
9
 
10
- def initialize(project_root_directory:, storage_directory:, web_url:, live_websocket_url:)
10
+ def initialize(project_root_directory:, storage_directory:, web_url:)
11
11
  @project_root_directory = project_root_directory
12
12
  @storage_directory = storage_directory
13
13
  @web_url = web_url
14
- @live_websocket_url = live_websocket_url
15
14
  end
16
15
 
17
16
  def storage_directory
@@ -22,6 +21,16 @@ module SnapshotUI
22
21
  Pathname.new(@project_root_directory) if @project_root_directory
23
22
  end
24
23
 
24
+ # Live updates no longer need a separate WebSocket server: the web UI polls
25
+ # for changes instead. Kept so that existing configuration files keep working.
26
+ def live_websocket_url=(_value)
27
+ warn(
28
+ "[snapshot_ui] `config.live_websocket_url` is deprecated and has no effect: " \
29
+ "the web UI now polls for snapshot updates. Remove it from your configuration.",
30
+ uplevel: 1
31
+ )
32
+ end
33
+
25
34
  def exit_if_not_configured!
26
35
  return unless project_root_directory.nil? || storage_directory.nil? || web_url.nil?
27
36
 
@@ -3,7 +3,7 @@
3
3
  module SnapshotUI
4
4
  class Snapshot
5
5
  class Context
6
- attr_reader :test_framework, :test_case_name, :method_name, :source_location, :take_snapshot_index
6
+ attr_reader :test_framework, :test_case_name, :method_name, :source_location, :take_snapshot_index, :metadata
7
7
 
8
8
  def initialize(context)
9
9
  @test_framework = context[:test_framework]
@@ -11,9 +11,12 @@ module SnapshotUI
11
11
  @method_name = context[:method_name]
12
12
  @source_location = context[:source_location]
13
13
  @take_snapshot_index = context[:take_snapshot_index]
14
+ @metadata = context[:metadata]
14
15
  end
15
16
 
16
17
  def to_slug
18
+ return metadata[:slug] if metadata[:slug]
19
+
17
20
  test_path_without_extension =
18
21
  source_location[0]
19
22
  .delete_suffix(File.extname(source_location[0]))
@@ -3,6 +3,8 @@
3
3
  module SnapshotUI
4
4
  class Snapshot
5
5
  class Storage
6
+ class InvalidKey < StandardError; end
7
+
6
8
  class << self
7
9
  def snapshots_directory
8
10
  SnapshotUI.configuration.storage_directory.join("snapshots")
@@ -12,6 +14,25 @@ module SnapshotUI
12
14
  SnapshotUI.configuration.storage_directory.join("in_progress")
13
15
  end
14
16
 
17
+ # Holds the previously published snapshots for the brief moment while the
18
+ # ones in progress take their place.
19
+ def previous_snapshots_directory
20
+ SnapshotUI.configuration.storage_directory.join("previous_snapshots")
21
+ end
22
+
23
+ # A token that changes whenever the published snapshots change.
24
+ #
25
+ # Publishing swaps in a freshly created directory (see
26
+ # .publish_snapshots_in_progress), so its inode identifies the published
27
+ # set even when the filesystem gives consecutive directories the same
28
+ # modification time.
29
+ def version
30
+ return "0" unless snapshots_directory.exist?
31
+
32
+ stat = snapshots_directory.stat
33
+ format("%d.%09d-%d", stat.mtime.to_i, stat.mtime.nsec, stat.ino)
34
+ end
35
+
15
36
  def write(key, value)
16
37
  file_path = to_file_path_for_writing(key)
17
38
  file_path.dirname.mkpath
@@ -22,6 +43,16 @@ module SnapshotUI
22
43
  to_file_path_for_reading(key).read
23
44
  end
24
45
 
46
+ # True when +key+ maps to a file inside the snapshots directory. A slug
47
+ # coming from the URL is untrusted, so paths escaping the directory
48
+ # (via "..", say) must be refused.
49
+ def readable?(key)
50
+ to_file_path_for_reading(key)
51
+ true
52
+ rescue InvalidKey
53
+ false
54
+ end
55
+
25
56
  def list
26
57
  Dir
27
58
  .glob("#{snapshots_directory}/**/*.{json}")
@@ -37,12 +68,19 @@ module SnapshotUI
37
68
  else
38
69
  snapshots_directory.rmtree
39
70
  in_progress_directory.rmtree
71
+ previous_snapshots_directory.rmtree if previous_snapshots_directory.exist?
40
72
  end
41
73
  end
42
74
 
75
+ # Replaces the published snapshots with the ones in progress. The published
76
+ # directory is moved aside rather than deleted first, so that there is
77
+ # practically no moment without published snapshots (the web UI polls for
78
+ # changes and would otherwise briefly show an empty list).
43
79
  def publish_snapshots_in_progress
44
- clear(:snapshots)
80
+ previous_snapshots_directory.rmtree if previous_snapshots_directory.exist?
81
+ snapshots_directory.rename(previous_snapshots_directory) if snapshots_directory.exist?
45
82
  in_progress_directory.rename(snapshots_directory)
83
+ previous_snapshots_directory.rmtree if previous_snapshots_directory.exist?
46
84
  end
47
85
 
48
86
  private
@@ -52,11 +90,24 @@ module SnapshotUI
52
90
  end
53
91
 
54
92
  def to_file_path_for_reading(key)
55
- snapshots_directory.join("#{key}.json")
93
+ within(snapshots_directory, key)
56
94
  end
57
95
 
58
96
  def to_file_path_for_writing(key)
59
- in_progress_directory.join("#{key}.json")
97
+ within(in_progress_directory, key)
98
+ end
99
+
100
+ # Resolves +key+ to a "#{key}.json" file and ensures it stays inside
101
+ # +directory+, raising InvalidKey otherwise.
102
+ def within(directory, key)
103
+ directory = directory.expand_path
104
+ file_path = directory.join("#{key}.json").expand_path
105
+
106
+ unless file_path == directory || file_path.to_s.start_with?(directory.to_s + File::SEPARATOR)
107
+ raise InvalidKey, "#{key.inspect} is not a valid snapshot slug."
108
+ end
109
+
110
+ file_path
60
111
  end
61
112
  end
62
113
  end
@@ -6,19 +6,27 @@ require "json"
6
6
 
7
7
  module SnapshotUI
8
8
  class Snapshot
9
- attr_reader :slug, :context, :body
9
+ # The kind of thing a snapshot holds. "response" is the default and the only
10
+ # type the core gem knows how to capture; other types (e.g. "mail", added by
11
+ # snapshot_ui-rails) store their own data under +type_data+ and are rendered
12
+ # by a registered renderer (see SnapshotUI::Web).
13
+ DEFAULT_TYPE = "response"
14
+
15
+ attr_reader :slug, :context, :type, :type_data
10
16
 
11
17
  class NotFound < StandardError; end
12
18
 
13
- def self.persist(snapshotee:, context:)
14
- new.extract(snapshotee: snapshotee, context: context).persist
19
+ def self.persist(snapshotee:, context:, type: DEFAULT_TYPE, type_data: nil)
20
+ new.extract(snapshotee: snapshotee, context: context, type: type, type_data: type_data).persist
15
21
  end
16
22
 
17
23
  def self.find(slug)
18
24
  json = JSON.parse(Storage.read(slug), symbolize_names: true)
19
25
  new.from_json(json)
20
- rescue Errno::ENOENT
26
+ rescue Errno::ENOENT, Errno::EISDIR, Storage::InvalidKey
21
27
  raise NotFound.new("Snapshot with a slug `#{slug}` can't be found.")
28
+ rescue JSON::ParserError => error
29
+ raise NotFound.new("Snapshot with a slug `#{slug}` can't be read: #{error.message}")
22
30
  end
23
31
 
24
32
  def self.grouped_by_test_case
@@ -40,6 +48,12 @@ module SnapshotUI
40
48
  Storage.clear
41
49
  end
42
50
 
51
+ # A token that changes whenever the published snapshots change. The web UI
52
+ # polls it to know when to refresh.
53
+ def self.version
54
+ Storage.version
55
+ end
56
+
43
57
  private_class_method def self.all
44
58
  snapshots = Storage.list.map { |slug| find(slug) }
45
59
 
@@ -52,9 +66,12 @@ module SnapshotUI
52
66
  end
53
67
  end
54
68
 
55
- def extract(snapshotee:, context:)
56
- @body = snapshotee.body
57
- @snapshotee_class = snapshotee.class.to_s
69
+ # +type_data+ lets a caller supply the stored payload directly (used by
70
+ # snapshot_ui-rails for mail). When omitted, the response body is extracted
71
+ # from the snapshotee.
72
+ def extract(snapshotee:, context:, type: DEFAULT_TYPE, type_data: nil)
73
+ @type = type
74
+ @type_data = type_data || extract_response_type_data(snapshotee)
58
75
  @context = Context.new(context)
59
76
  self
60
77
  end
@@ -63,28 +80,52 @@ module SnapshotUI
63
80
  Storage.write(context.to_slug, JSON.pretty_generate(as_json))
64
81
  end
65
82
 
83
+ # The response body, for the default "response" type.
84
+ def body
85
+ type_data[:body]
86
+ end
87
+
88
+ def response?
89
+ type == DEFAULT_TYPE
90
+ end
91
+
66
92
  def as_json
67
93
  {
68
- type_data: {
69
- snapshotee_class: @snapshotee_class,
70
- body: body
71
- },
94
+ type: type,
95
+ type_data: type_data,
72
96
  context: {
73
97
  test_framework: context.test_framework,
74
98
  test_case_name: context.test_case_name,
75
99
  method_name: context.method_name,
76
100
  source_location: context.source_location,
77
- take_snapshot_index: context.take_snapshot_index
101
+ take_snapshot_index: context.take_snapshot_index,
102
+ metadata: context.metadata
78
103
  },
79
104
  slug: context.to_slug
80
105
  }
81
106
  end
82
107
 
83
108
  def from_json(json)
84
- @body = json[:type_data][:body]
109
+ # Snapshots written before types existed have no :type and their body sits
110
+ # directly under :type_data — treat them as responses.
111
+ @type = json[:type] || DEFAULT_TYPE
112
+ @type_data = json[:type_data] || {}
85
113
  @context = Context.new(json[:context])
86
114
  @slug = json[:slug]
87
115
  self
88
116
  end
117
+
118
+ private
119
+
120
+ def extract_response_type_data(snapshotee)
121
+ body =
122
+ if snapshotee.respond_to?(:body)
123
+ snapshotee.body
124
+ elsif snapshotee.is_a?(String)
125
+ snapshotee
126
+ end
127
+
128
+ {body: body}
129
+ end
89
130
  end
90
131
  end
@@ -2,18 +2,32 @@
2
2
 
3
3
  require_relative "../../snapshot_ui"
4
4
  require_relative "../snapshot"
5
+ require_relative "../../minitest/snapshot_ui_plugin"
5
6
 
6
7
  module SnapshotUI
7
8
  module Test
8
9
  module MinitestHelpers
9
- def take_snapshot(snapshotee)
10
+ # Takes a snapshot of a given +snapshotee+, with an optional +title+ metadata.
11
+ #
12
+ # A +snapshotee+ can be an object that responds to +#body+ or of class +String+.
13
+ #
14
+ # +take_snapshot+ needs to be called after the +snapshotee+ object becomes available
15
+ # for inspection in the lifecycle of the test. You can take one or more snapshots in a single test case.
16
+ #
17
+ # @param snapshotee [Object, String] an +Object+ that responds to the +#body+ method that returns a +String+, or a +String+
18
+ # @param title [String, nil] (optional) a title of the snapshotee. When not provided the title will be generated from a test description.
19
+ # @param slug [String, nil] (optional) a custom URL path for the snapshot (e.g. +/ui/snapshots/__SLUG__+). +slug+ must be unique across all snapshots.
20
+ # If not provided, the slug will be automatically generated based on where the snapshot was taken.
21
+ # @return SnapshotUI::Snapshot
22
+ def take_snapshot(snapshotee, title: nil, slug: nil)
10
23
  return unless SnapshotUI.snapshot_taking_enabled?
11
24
 
12
- unless snapshotee.respond_to?(:body)
25
+ unless snapshotee.respond_to?(:body) || snapshotee.is_a?(String)
13
26
  message =
14
- "#take_snapshot only accepts an argument that responds to a method `#body`. " \
15
- "You provided an argument of type `#{snapshotee.class}` that does not respond to `#body`."
16
- raise ArgumentError.new(message)
27
+ "#take_snapshot only accepts an argument that responds to a method `#body` " \
28
+ "or an argument that is of type `String`. " \
29
+ "You provided an argument of type `#{snapshotee.class}` that does not respond to `#body` nor is a `String`."
30
+ raise ArgumentError, message
17
31
  end
18
32
 
19
33
  SnapshotUI.exit_if_not_configured!
@@ -27,7 +41,8 @@ module SnapshotUI
27
41
  method_name: name,
28
42
  source_location: build_source_location(caller_locations(1..1).first),
29
43
  test_case_name: self.class.to_s,
30
- take_snapshot_index: _take_snapshot_counter - 1
44
+ take_snapshot_index: _take_snapshot_counter - 1,
45
+ metadata: {title: title, slug: slug}
31
46
  }
32
47
  )
33
48
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SnapshotUI
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end