snapshot_ui 0.4.0 → 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 +28 -0
- data/README.md +145 -4
- data/lib/minitest/snapshot_ui_plugin.rb +9 -0
- data/lib/snapshot_ui/colorize.rb +15 -4
- data/lib/snapshot_ui/configuration.rb +12 -3
- data/lib/snapshot_ui/snapshot/context.rb +4 -1
- data/lib/snapshot_ui/snapshot/storage.rb +54 -3
- data/lib/snapshot_ui/snapshot.rb +54 -13
- data/lib/snapshot_ui/test/minitest_helpers.rb +21 -6
- data/lib/snapshot_ui/version.rb +1 -1
- data/lib/snapshot_ui/web/application.rb +100 -39
- data/lib/snapshot_ui/web/assets/javascripts/application.js +4 -2
- data/lib/snapshot_ui/web/assets/javascripts/controllers/refresh_controller.js +76 -12
- data/lib/snapshot_ui/web/assets/javascripts/controllers/source_location_controller.js +14 -6
- data/lib/snapshot_ui/web/assets/javascripts/vendor/stimulus.js +2564 -0
- data/lib/snapshot_ui/web/assets/javascripts/vendor/turbo.js +7257 -0
- data/lib/snapshot_ui/web/assets/stylesheets/application.css +121 -0
- data/lib/snapshot_ui/web/views/layout.html.erb +4 -9
- data/lib/snapshot_ui/web/views/snapshots/index.html.erb +5 -11
- data/lib/snapshot_ui/web.rb +27 -10
- data/lib/snapshot_ui.rb +4 -2
- data/snapshot_ui.gemspec +8 -14
- metadata +17 -76
- data/bin/snapshot_ui +0 -7
- data/lib/snapshot_ui/cli/watcher.ru +0 -91
- data/lib/snapshot_ui/cli.rb +0 -29
- data/lib/snapshot_ui/web/assets/javascripts/channels/consumer.js +0 -3
- data/lib/snapshot_ui/web/assets/javascripts/channels/index.js +0 -1
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69cf45167a5e8d9a8bd93ad9494521ef09da395e5224471de4023b93c926ede0
|
|
4
|
+
data.tar.gz: 75866a6374d237f1a526b15f563d287a713e6c6b8fa5f619fe811190f08c7ae5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
3
|
+
Take snapshots of responses in your integration tests and view them in a browser.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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)
|
data/lib/snapshot_ui/colorize.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
+
def red(string, io: $stdout)
|
|
9
|
+
colorize(string, 31, io: io)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
def green(string)
|
|
12
|
-
|
|
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
|
|
8
|
+
attr_accessor :web_url
|
|
9
9
|
|
|
10
|
-
def initialize(project_root_directory:, storage_directory:, web_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
|
-
|
|
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
|
|
93
|
+
within(snapshots_directory, key)
|
|
56
94
|
end
|
|
57
95
|
|
|
58
96
|
def to_file_path_for_writing(key)
|
|
59
|
-
in_progress_directory
|
|
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
|
data/lib/snapshot_ui/snapshot.rb
CHANGED
|
@@ -6,19 +6,27 @@ require "json"
|
|
|
6
6
|
|
|
7
7
|
module SnapshotUI
|
|
8
8
|
class Snapshot
|
|
9
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
16
|
-
|
|
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
|
data/lib/snapshot_ui/version.rb
CHANGED