snapshot_ui 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +16 -0
- data/bin/snapshot_ui +7 -0
- data/lib/snapshot_ui/cli/watcher.ru +89 -0
- data/lib/snapshot_ui/cli.rb +26 -0
- data/lib/snapshot_ui/configuration.rb +20 -0
- data/lib/snapshot_ui/snapshot/context.rb +38 -0
- data/lib/snapshot_ui/snapshot/storage.rb +64 -0
- data/lib/snapshot_ui/snapshot.rb +90 -0
- data/lib/snapshot_ui/test/minitest_helpers.rb +47 -0
- data/lib/snapshot_ui/version.rb +5 -0
- data/lib/snapshot_ui/web/application.rb +124 -0
- data/lib/snapshot_ui/web/assets/javascripts/application.js +8 -0
- data/lib/snapshot_ui/web/assets/javascripts/channels/consumer.js +3 -0
- data/lib/snapshot_ui/web/assets/javascripts/channels/index.js +1 -0
- data/lib/snapshot_ui/web/assets/javascripts/channels/refresh_channel.js +27 -0
- data/lib/snapshot_ui/web/assets/javascripts/controllers/refresh_controller.js +25 -0
- data/lib/snapshot_ui/web/assets/stylesheets/application.css +108 -0
- data/lib/snapshot_ui/web/views/layout.html.erb +30 -0
- data/lib/snapshot_ui/web/views/snapshots/index.html.erb +60 -0
- data/lib/snapshot_ui/web/views/snapshots/not_found.html.erb +8 -0
- data/lib/snapshot_ui/web/views/snapshots/show.html.erb +3 -0
- data/lib/snapshot_ui/web.rb +27 -0
- data/lib/snapshot_ui.rb +35 -0
- data/snapshot_ui.gemspec +35 -0
- metadata +144 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: db2e79495a612dd8f9e73f84537b5237c214faf5290915f40a451b27c517fb10
|
4
|
+
data.tar.gz: 5f68e150a5439073be54508096b9a085277d1cfe03292a78e396c6f790c17a0d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e20faf53a0f05035d90e76e10d08a7a067728899df4bc6e5dc9c3dda944c7414579b334650c4b9a8edfc1ad2889bb7b65537d22120cb9cbc2a92b4b0378d2598
|
7
|
+
data.tar.gz: c6fa887d47cfec6bf20f91146e37f0165655601b4d3a38bb43e74b735dcde65da4453a672d147cb305f0cc67dc4d5338ece722b7224f428527917e8f49b30370
|
data/CHANGELOG.md
ADDED
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,16 @@
|
|
1
|
+
# SnapshotUI
|
2
|
+
|
3
|
+
## Things to do next
|
4
|
+
|
5
|
+
- Live reloads after a test run and new snapshots become published
|
6
|
+
- Accept not only "response" objects but also just plain strings for testing UI helpers (think buttons, etc.)
|
7
|
+
- Make it easier to navigate between a list of snapshots and the code
|
8
|
+
- Ability to disable or enable javascript of the rendered snapshots
|
9
|
+
|
10
|
+
## Things to do in the far future
|
11
|
+
|
12
|
+
- Different representations of snapshot lists
|
13
|
+
- Spatially display snapshots and how they connect to each other to demonstrate UI flows
|
14
|
+
- Extract more information out of response snapshots - path, page title, how they connect to other snapshots
|
15
|
+
- Possibility to add notes to snapshots from within tests (`take_snapshot response, note: "...")
|
16
|
+
|
data/bin/snapshot_ui
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "async"
|
4
|
+
require "async/http/endpoint"
|
5
|
+
require "async/websocket/client"
|
6
|
+
require "async/websocket/adapters/rack"
|
7
|
+
require "listen"
|
8
|
+
require "set"
|
9
|
+
|
10
|
+
SNAPSHOT_DIRECTORY = ENV.fetch("SNAPSHOT_DIRECTORY")
|
11
|
+
WEBSOCKET_ENDPOINT = "http://localhost:7070/cable"
|
12
|
+
|
13
|
+
REFRESH_MESSAGE = {identifier: "{\"channel\":\"RefreshChannel\"}", message: "refresh"}.to_json
|
14
|
+
CONFIRM_SUBSCRIPTION_MESSAGE = {identifier: "{\"channel\":\"RefreshChannel\"}", type: "confirm_subscription"}.to_json
|
15
|
+
PING_MESSAGE = {type: "ping", message: Time.now.to_i.to_s}.to_json
|
16
|
+
|
17
|
+
@connections = Set.new
|
18
|
+
|
19
|
+
run lambda { |env|
|
20
|
+
if env["REQUEST_PATH"] == "/cable"
|
21
|
+
Async::WebSocket::Adapters::Rack.open(env, protocols: ["actioncable-v1-json"]) do |connection|
|
22
|
+
@connections << connection
|
23
|
+
|
24
|
+
Console.info "Client connected."
|
25
|
+
connection.write(CONFIRM_SUBSCRIPTION_MESSAGE)
|
26
|
+
connection.flush
|
27
|
+
|
28
|
+
Async do |ping_task|
|
29
|
+
loop do
|
30
|
+
connection.write(PING_MESSAGE)
|
31
|
+
connection.flush
|
32
|
+
sleep(2)
|
33
|
+
end
|
34
|
+
ensure
|
35
|
+
ping_task&.stop
|
36
|
+
end
|
37
|
+
|
38
|
+
while connection.read
|
39
|
+
end
|
40
|
+
rescue Protocol::WebSocket::ClosedError
|
41
|
+
Console.info "Client disconnected."
|
42
|
+
@connections.delete(connection)
|
43
|
+
ensure
|
44
|
+
Console.info "Client disconnected."
|
45
|
+
@connections.delete(connection)
|
46
|
+
end
|
47
|
+
else
|
48
|
+
[404, {}, ["404 Not Found"]]
|
49
|
+
end
|
50
|
+
}
|
51
|
+
|
52
|
+
Async do |client_task|
|
53
|
+
endpoint = Async::HTTP::Endpoint.parse(WEBSOCKET_ENDPOINT)
|
54
|
+
|
55
|
+
Async::WebSocket::Client.connect(endpoint) do |connection|
|
56
|
+
Async do |listener_task|
|
57
|
+
detect_snapshots_update(listener_task)
|
58
|
+
end
|
59
|
+
|
60
|
+
while (message = connection.read)
|
61
|
+
if message == REFRESH_MESSAGE
|
62
|
+
Console.info "Snapshots updated."
|
63
|
+
end
|
64
|
+
end
|
65
|
+
ensure
|
66
|
+
client_task&.stop
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def detect_snapshots_update(task)
|
71
|
+
Pathname.new(SNAPSHOT_DIRECTORY).mkpath
|
72
|
+
listener = Listen.to(SNAPSHOT_DIRECTORY) { |_modified, _added, _removed| broadcast_update }
|
73
|
+
|
74
|
+
task.async do
|
75
|
+
listener.start
|
76
|
+
task.sleep
|
77
|
+
end
|
78
|
+
|
79
|
+
Console.info("Watching for snapshots updates in #{SNAPSHOT_DIRECTORY}...")
|
80
|
+
end
|
81
|
+
|
82
|
+
def broadcast_update
|
83
|
+
@connections.each do |connection|
|
84
|
+
connection.write(REFRESH_MESSAGE)
|
85
|
+
connection.flush
|
86
|
+
rescue => e
|
87
|
+
Console.error "Failed to send message to client: #{e.message}"
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require "bundler/setup"
|
2
|
+
require "thor"
|
3
|
+
require "pathname"
|
4
|
+
|
5
|
+
module SnapshotUI
|
6
|
+
class CLI < Thor
|
7
|
+
WEBSOCKET_HOST = "localhost:7070"
|
8
|
+
|
9
|
+
desc "watch SNAPSHOT_DIRECTORY", "Watches for snapshot changes in SNAPSHOT_DIRECTORY and broadcasts them on ws://#{WEBSOCKET_HOST}/cable."
|
10
|
+
def watch(snapshot_directory)
|
11
|
+
unless File.exist?(snapshot_directory)
|
12
|
+
puts "The provided directory `#{snapshot_directory}` doesn't exist. Please double check the path."
|
13
|
+
exit 1
|
14
|
+
end
|
15
|
+
|
16
|
+
websocket_host = "http://#{WEBSOCKET_HOST}"
|
17
|
+
config_path = Pathname.new(__dir__).join("cli", "watcher.ru").cleanpath.to_s
|
18
|
+
|
19
|
+
exec "SNAPSHOT_DIRECTORY=#{snapshot_directory} bundle exec falcon serve --bind #{websocket_host} --count 1 --config #{config_path}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.exit_on_failure?
|
23
|
+
true
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SnapshotUI
|
4
|
+
class Configuration
|
5
|
+
attr_writer :storage_directory, :project_root_directory
|
6
|
+
|
7
|
+
def initialize(project_root_directory:, storage_directory:)
|
8
|
+
@project_root_directory = project_root_directory
|
9
|
+
@storage_directory = storage_directory
|
10
|
+
end
|
11
|
+
|
12
|
+
def storage_directory
|
13
|
+
Pathname.new(@storage_directory)
|
14
|
+
end
|
15
|
+
|
16
|
+
def project_root_directory
|
17
|
+
Pathname.new(@project_root_directory)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SnapshotUI
|
4
|
+
class Snapshot
|
5
|
+
class Context
|
6
|
+
attr_reader :test_framework, :test_case_name, :method_name, :source_location, :take_snapshot_index
|
7
|
+
|
8
|
+
def initialize(context)
|
9
|
+
@test_framework = context[:test_framework]
|
10
|
+
@test_case_name = context[:test_case_name]
|
11
|
+
@method_name = context[:method_name]
|
12
|
+
@source_location = context[:source_location]
|
13
|
+
@take_snapshot_index = context[:take_snapshot_index]
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_slug
|
17
|
+
test_path_without_extension =
|
18
|
+
source_location[0]
|
19
|
+
.delete_suffix(File.extname(source_location[0]))
|
20
|
+
.delete_prefix(SnapshotUI.configuration.project_root_directory.to_s + "/")
|
21
|
+
|
22
|
+
[test_path_without_extension, source_location[1], take_snapshot_index].join("_")
|
23
|
+
end
|
24
|
+
|
25
|
+
def name
|
26
|
+
method_name
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_group
|
30
|
+
test_case_name
|
31
|
+
end
|
32
|
+
|
33
|
+
def order_index
|
34
|
+
source_location.dup << take_snapshot_index
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SnapshotUI
|
4
|
+
class Snapshot
|
5
|
+
class Storage
|
6
|
+
class << self
|
7
|
+
def snapshots_directory
|
8
|
+
SnapshotUI.configuration.storage_directory.join("snapshots")
|
9
|
+
end
|
10
|
+
|
11
|
+
def in_progress_directory
|
12
|
+
SnapshotUI.configuration.storage_directory.join("in_progress")
|
13
|
+
end
|
14
|
+
|
15
|
+
def write(key, value)
|
16
|
+
file_path = to_file_path_for_writing(key)
|
17
|
+
file_path.dirname.mkpath
|
18
|
+
file_path.write(value)
|
19
|
+
end
|
20
|
+
|
21
|
+
def read(key)
|
22
|
+
to_file_path_for_reading(key).read
|
23
|
+
end
|
24
|
+
|
25
|
+
def list
|
26
|
+
Dir
|
27
|
+
.glob("#{snapshots_directory}/**/*.{json}")
|
28
|
+
.map { |file_path| to_key(file_path) }
|
29
|
+
end
|
30
|
+
|
31
|
+
def clear(directory = nil)
|
32
|
+
case directory
|
33
|
+
when :snapshots
|
34
|
+
snapshots_directory.rmtree
|
35
|
+
when :in_progress
|
36
|
+
in_progress_directory.rmtree
|
37
|
+
else
|
38
|
+
snapshots_directory.rmtree
|
39
|
+
in_progress_directory.rmtree
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def publish_snapshots_in_progress
|
44
|
+
clear(:snapshots)
|
45
|
+
in_progress_directory.rename(snapshots_directory)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def to_key(file_path)
|
51
|
+
file_path.gsub(snapshots_directory.to_s + "/", "").gsub(".json", "")
|
52
|
+
end
|
53
|
+
|
54
|
+
def to_file_path_for_reading(key)
|
55
|
+
snapshots_directory.join("#{key}.json")
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_file_path_for_writing(key)
|
59
|
+
in_progress_directory.join("#{key}.json")
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "snapshot/context"
|
4
|
+
require_relative "snapshot/storage"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
module SnapshotUI
|
8
|
+
class Snapshot
|
9
|
+
attr_reader :slug, :context, :body
|
10
|
+
|
11
|
+
class NotFound < StandardError; end
|
12
|
+
|
13
|
+
def self.persist(snapshotee:, context:)
|
14
|
+
new.extract(snapshotee: snapshotee, context: context).persist
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.find(slug)
|
18
|
+
json = JSON.parse(Storage.read(slug), symbolize_names: true)
|
19
|
+
new.from_json(json)
|
20
|
+
rescue Errno::ENOENT
|
21
|
+
raise NotFound.new("Snapshot with a slug `#{slug}` can't be found.")
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.grouped_by_test_case
|
25
|
+
all.group_by do |snapshot|
|
26
|
+
snapshot.context.test_group
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.publish_snapshots_in_progress
|
31
|
+
return unless SnapshotUI::Snapshot::Storage.in_progress_directory.exist?
|
32
|
+
SnapshotUI::Snapshot::Storage.publish_snapshots_in_progress
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.clear_snapshots_in_progress
|
36
|
+
Storage.clear(:in_progress)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.clear_snapshots
|
40
|
+
Storage.clear
|
41
|
+
end
|
42
|
+
|
43
|
+
private_class_method def self.all
|
44
|
+
snapshots = Storage.list.map { |slug| find(slug) }
|
45
|
+
|
46
|
+
order_by_line_number(snapshots)
|
47
|
+
end
|
48
|
+
|
49
|
+
private_class_method def self.order_by_line_number(snapshots)
|
50
|
+
snapshots.sort_by do |snapshot|
|
51
|
+
snapshot.context.order_index
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def extract(snapshotee:, context:)
|
56
|
+
@body = snapshotee.body
|
57
|
+
@snapshotee_class = snapshotee.class.to_s
|
58
|
+
@context = Context.new(context)
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
def persist
|
63
|
+
Storage.write(context.to_slug, JSON.pretty_generate(as_json))
|
64
|
+
end
|
65
|
+
|
66
|
+
def as_json
|
67
|
+
{
|
68
|
+
type_data: {
|
69
|
+
snapshotee_class: @snapshotee_class,
|
70
|
+
body: body
|
71
|
+
},
|
72
|
+
context: {
|
73
|
+
test_framework: context.test_framework,
|
74
|
+
test_case_name: context.test_case_name,
|
75
|
+
method_name: context.method_name,
|
76
|
+
source_location: context.source_location,
|
77
|
+
take_snapshot_index: context.take_snapshot_index
|
78
|
+
},
|
79
|
+
slug: context.to_slug
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
def from_json(json)
|
84
|
+
@body = json[:type_data][:body]
|
85
|
+
@context = Context.new(json[:context])
|
86
|
+
@slug = json[:slug]
|
87
|
+
self
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../snapshot_ui"
|
4
|
+
require_relative "../snapshot"
|
5
|
+
|
6
|
+
module SnapshotUI
|
7
|
+
module Test
|
8
|
+
module MinitestHelpers
|
9
|
+
def take_snapshot(snapshotee)
|
10
|
+
return unless SnapshotUI.snapshot_taking_enabled?
|
11
|
+
|
12
|
+
unless snapshotee.respond_to?(:body)
|
13
|
+
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)
|
17
|
+
end
|
18
|
+
|
19
|
+
increment_take_snapshot_counter_scoped_by_test
|
20
|
+
|
21
|
+
SnapshotUI::Snapshot.persist(
|
22
|
+
snapshotee: snapshotee,
|
23
|
+
context: {
|
24
|
+
test_framework: "minitest",
|
25
|
+
method_name: name,
|
26
|
+
source_location: build_source_location(caller_locations(1..1).first),
|
27
|
+
test_case_name: self.class.to_s,
|
28
|
+
take_snapshot_index: _take_snapshot_counter - 1
|
29
|
+
}
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
attr_reader :_take_snapshot_counter
|
36
|
+
|
37
|
+
def increment_take_snapshot_counter_scoped_by_test
|
38
|
+
@_take_snapshot_counter ||= 0
|
39
|
+
@_take_snapshot_counter += 1
|
40
|
+
end
|
41
|
+
|
42
|
+
def build_source_location(caller_location)
|
43
|
+
[caller_location.path, caller_location.lineno]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "erb"
|
4
|
+
require "rack/static"
|
5
|
+
require_relative "../snapshot"
|
6
|
+
require "listen"
|
7
|
+
|
8
|
+
module SnapshotUI
|
9
|
+
class Web
|
10
|
+
class Application
|
11
|
+
def call(env)
|
12
|
+
@request = Rack::Request.new(env)
|
13
|
+
|
14
|
+
if parse_root_path(@request.path_info)
|
15
|
+
@grouped_by_test_class = SnapshotUI::Snapshot.grouped_by_test_case
|
16
|
+
if @request.get_header("HTTP_ACCEPT") != "text/event-stream"
|
17
|
+
render("snapshots/index", status: 200)
|
18
|
+
else
|
19
|
+
[200, {"content-type" => "text/event-stream", "cache-control" => "no-cache", "connection" => "keep-alive"}, file_event_stream(SnapshotUI.configuration.storage_directory)]
|
20
|
+
end
|
21
|
+
elsif (slug = parse_raw_snapshot_path(@request.path_info))
|
22
|
+
@snapshot = Snapshot.find(slug)
|
23
|
+
render_raw_response_body(@snapshot.body)
|
24
|
+
elsif (slug = parse_snapshot_path(@request.path_info))
|
25
|
+
@snapshot = Snapshot.find(slug)
|
26
|
+
render("snapshots/show", status: 200)
|
27
|
+
else
|
28
|
+
render("snapshots/not_found", status: 200)
|
29
|
+
end
|
30
|
+
rescue SnapshotUI::Snapshot::NotFound
|
31
|
+
render("snapshots/not_found", status: 200)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def file_event_stream(directory)
|
37
|
+
message = '<turbo-stream action="refresh"></turbo-stream>'
|
38
|
+
|
39
|
+
Enumerator.new do |stream|
|
40
|
+
listener = Listen.to(directory) do |_modified, _added, _removed|
|
41
|
+
stream << "data: #{message}\n\n"
|
42
|
+
end
|
43
|
+
|
44
|
+
listener.start
|
45
|
+
sleep
|
46
|
+
rescue Puma::ConnectionError
|
47
|
+
listener.stop
|
48
|
+
puts "Puma::ConnectionError"
|
49
|
+
rescue Errno::EPIPE
|
50
|
+
listener.stop
|
51
|
+
puts "Errno::EPIPE"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def render_raw_response_body(response_body)
|
56
|
+
[200, {"content-type" => "text/html; charset=utf-8"}, [response_body]]
|
57
|
+
end
|
58
|
+
|
59
|
+
def render(template, status:)
|
60
|
+
rendered_view = ERB.new(read_template(template)).result(binding)
|
61
|
+
response_body = ERB.new(read_template("layout")).result(get_binding { rendered_view })
|
62
|
+
response_headers = {"content-type" => "text/html; charset=utf-8"}
|
63
|
+
|
64
|
+
[status, response_headers, [response_body]]
|
65
|
+
end
|
66
|
+
|
67
|
+
def get_binding
|
68
|
+
binding
|
69
|
+
end
|
70
|
+
|
71
|
+
def read_template(template)
|
72
|
+
File.read(template_path(template))
|
73
|
+
end
|
74
|
+
|
75
|
+
def template_path(template)
|
76
|
+
"#{File.dirname(__FILE__)}/views/#{template}.html.erb"
|
77
|
+
end
|
78
|
+
|
79
|
+
def root_path
|
80
|
+
@request.env["SCRIPT_NAME"]
|
81
|
+
end
|
82
|
+
|
83
|
+
def stylesheet_path(stylesheet)
|
84
|
+
[root_path, "stylesheets", stylesheet].join("/")
|
85
|
+
end
|
86
|
+
|
87
|
+
def javascript_path(stylesheet)
|
88
|
+
[root_path, "javascripts", stylesheet].join("/")
|
89
|
+
end
|
90
|
+
|
91
|
+
def snapshot_path(slug)
|
92
|
+
[root_path, "response", slug].join("/")
|
93
|
+
end
|
94
|
+
|
95
|
+
def raw_snapshot_path(slug)
|
96
|
+
[root_path, "response", "raw", slug].join("/")
|
97
|
+
end
|
98
|
+
|
99
|
+
def parse_snapshot_path(path)
|
100
|
+
pattern = %r{^/response/(?<slug>.+)$}
|
101
|
+
|
102
|
+
if (match = pattern.match(path))
|
103
|
+
match[:slug]
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def parse_raw_snapshot_path(path)
|
108
|
+
pattern = %r{^/response/raw/(?<slug>.+)$}
|
109
|
+
|
110
|
+
if (match = pattern.match(path))
|
111
|
+
match[:slug]
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def parse_root_path(path)
|
116
|
+
path == "" || path == "/"
|
117
|
+
end
|
118
|
+
|
119
|
+
def refresh_controller
|
120
|
+
'data-controller="refresh" data-action="refresh-connected@window->refresh#connected refresh-disconnected@window->refresh#disconnected turbo:before-render@window->refresh#display_status"'
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
import "channels/refresh_channel"
|
@@ -0,0 +1,27 @@
|
|
1
|
+
import consumer from "channels/consumer"
|
2
|
+
import * as Turbo from "@hotwired/turbo"
|
3
|
+
|
4
|
+
consumer.subscriptions.create("RefreshChannel", {
|
5
|
+
connected() {
|
6
|
+
console.log("Connected. Waiting for snapshots updates...")
|
7
|
+
|
8
|
+
const connected_event = new CustomEvent("refresh-connected");
|
9
|
+
window.dispatchEvent(connected_event);
|
10
|
+
},
|
11
|
+
|
12
|
+
disconnected() {
|
13
|
+
console.log("Unsubscribed from snapshots updates. Trying to reconnect...")
|
14
|
+
|
15
|
+
const disconnected_event = new CustomEvent("refresh-disconnected");
|
16
|
+
window.dispatchEvent(disconnected_event);
|
17
|
+
},
|
18
|
+
|
19
|
+
received(_data) {
|
20
|
+
console.log("Snapshots updated.")
|
21
|
+
Turbo.visit(location, { action: "replace" })
|
22
|
+
|
23
|
+
if (document.querySelector("iframe#raw")) {
|
24
|
+
document.querySelector("iframe#raw").contentWindow.location.reload()
|
25
|
+
}
|
26
|
+
}
|
27
|
+
});
|
@@ -0,0 +1,25 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
connect() {
|
5
|
+
this.display_status()
|
6
|
+
}
|
7
|
+
|
8
|
+
connected(_event) {
|
9
|
+
window.refresh_status = "connected"
|
10
|
+
this.display_status()
|
11
|
+
}
|
12
|
+
|
13
|
+
disconnected(_event) {
|
14
|
+
window.refresh_status = undefined
|
15
|
+
this.display_status()
|
16
|
+
}
|
17
|
+
|
18
|
+
display_status() {
|
19
|
+
if (window.refresh_status === "connected") {
|
20
|
+
document.title = "Snapshot UI - ✅ Connected"
|
21
|
+
} else {
|
22
|
+
document.title = "Snapshot UI - ⏳ Reconnecting..."
|
23
|
+
}
|
24
|
+
}
|
25
|
+
}
|
@@ -0,0 +1,108 @@
|
|
1
|
+
html {
|
2
|
+
height: 100%;
|
3
|
+
}
|
4
|
+
|
5
|
+
body {
|
6
|
+
background-color: #fff;
|
7
|
+
color: #333;
|
8
|
+
margin: 0;
|
9
|
+
padding: 0;
|
10
|
+
height: 100%;
|
11
|
+
}
|
12
|
+
|
13
|
+
body, p, ul {
|
14
|
+
font-family: helvetica, verdana, arial, sans-serif;
|
15
|
+
font-size: 20px;
|
16
|
+
line-height: 1.7em;
|
17
|
+
}
|
18
|
+
|
19
|
+
body#snapshots_index {
|
20
|
+
padding: 100px;
|
21
|
+
}
|
22
|
+
|
23
|
+
body#snapshots_index h1 {
|
24
|
+
margin: 0;
|
25
|
+
font-size: 30px;
|
26
|
+
}
|
27
|
+
|
28
|
+
body#snapshots_index h2 {
|
29
|
+
margin: 30px 0 0;
|
30
|
+
font-size: 24px;
|
31
|
+
}
|
32
|
+
|
33
|
+
body#snapshots_index p {
|
34
|
+
margin: 18px 0 0;
|
35
|
+
color: #666666;
|
36
|
+
}
|
37
|
+
|
38
|
+
body#snapshots_index ul {
|
39
|
+
margin: 3px 0 0;
|
40
|
+
}
|
41
|
+
|
42
|
+
body#snapshots_index h1, h2, h3, h4, h5, h6 {
|
43
|
+
font-weight: normal;
|
44
|
+
color: #666666;
|
45
|
+
}
|
46
|
+
|
47
|
+
body#snapshots_index a { color: #000; text-decoration: none; }
|
48
|
+
body#snapshots_index a:hover { text-decoration: underline; }
|
49
|
+
|
50
|
+
body#snapshots_index code {
|
51
|
+
background: rgba(175, 184, 193, 0.2);
|
52
|
+
font-size: 85%;
|
53
|
+
padding: .2em .4em;
|
54
|
+
border-radius: 6px;
|
55
|
+
font-family: Menlo, Courier, monospace;
|
56
|
+
}
|
57
|
+
|
58
|
+
body#snapshots_index pre {
|
59
|
+
background: rgb(246, 248, 250);;
|
60
|
+
font-size: 75%;
|
61
|
+
padding: 16px;
|
62
|
+
margin: 15px 0 0;
|
63
|
+
display: inline-block;
|
64
|
+
border-radius: 6px;
|
65
|
+
line-height: 1.5em;
|
66
|
+
font-family: Menlo, Courier, monospace;
|
67
|
+
}
|
68
|
+
|
69
|
+
body#snapshots_index pre mark {
|
70
|
+
background: lightgoldenrodyellow;
|
71
|
+
border: 1px solid #f1f1c3;
|
72
|
+
padding: 2px 3px;
|
73
|
+
position: relative;
|
74
|
+
left: -4px;
|
75
|
+
border-radius: 6px;
|
76
|
+
}
|
77
|
+
|
78
|
+
body#snapshots_show {
|
79
|
+
overflow: hidden;
|
80
|
+
}
|
81
|
+
|
82
|
+
body#snapshots_show iframe#raw {
|
83
|
+
border: 0;
|
84
|
+
width: 100%;
|
85
|
+
height: 100%;
|
86
|
+
}
|
87
|
+
|
88
|
+
body#not_found {
|
89
|
+
padding: 100px;
|
90
|
+
}
|
91
|
+
|
92
|
+
body#not_found h1 {
|
93
|
+
margin: 0;
|
94
|
+
font-size: 30px;
|
95
|
+
font-weight: normal;
|
96
|
+
color: #666666;
|
97
|
+
}
|
98
|
+
|
99
|
+
body#not_found p {
|
100
|
+
margin: 18px 0 0;
|
101
|
+
color: #666666;
|
102
|
+
}
|
103
|
+
|
104
|
+
/* Hotwired Turbo related */
|
105
|
+
.turbo-progress-bar {
|
106
|
+
height: 2px;
|
107
|
+
background-color: lightskyblue;
|
108
|
+
}
|
@@ -0,0 +1,30 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Snapshot UI</title>
|
5
|
+
<meta charset="utf-8">
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
7
|
+
<meta name="turbo-refresh-method" content="morph">
|
8
|
+
<meta name="turbo-refresh-scroll" content="preserve">
|
9
|
+
<link rel="stylesheet" href="<%= stylesheet_path("application.css") %>">
|
10
|
+
<meta name="action-cable-url" content="ws://127.0.0.1:7070/cable" />
|
11
|
+
<script type="importmap" data-turbo-track="reload">
|
12
|
+
{
|
13
|
+
"imports": {
|
14
|
+
"application": "<%= javascript_path("application.js") %>",
|
15
|
+
"@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm",
|
16
|
+
"@hotwired/turbo": "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/+esm",
|
17
|
+
"@rails/actioncable": "https://cdn.jsdelivr.net/npm/@rails/actioncable@7.1.3-4/+esm",
|
18
|
+
"controllers/refresh_controller": "<%= javascript_path("controllers/refresh_controller.js") %>",
|
19
|
+
"channels/consumer": "<%= javascript_path("channels/consumer.js") %>",
|
20
|
+
"channels": "<%= javascript_path("channels/index.js") %>",
|
21
|
+
"channels/refresh_channel": "<%= javascript_path("channels/refresh_channel.js") %>"
|
22
|
+
}
|
23
|
+
}
|
24
|
+
</script>
|
25
|
+
<script type="module">
|
26
|
+
import "application"
|
27
|
+
</script>
|
28
|
+
</head>
|
29
|
+
<%= yield %>
|
30
|
+
</html>
|
@@ -0,0 +1,60 @@
|
|
1
|
+
<body id="snapshots_index" <%= refresh_controller %>>
|
2
|
+
<h1>Snapshots</h1>
|
3
|
+
|
4
|
+
<% if @grouped_by_test_class.empty? %>
|
5
|
+
<p>
|
6
|
+
In your integration tests, once a request has been made,
|
7
|
+
add <code>#take_screenshot</code>. Run the tests with <code>TAKE_SNAPSHOTS=1</code>, and the snapshot will appear here.
|
8
|
+
</p>
|
9
|
+
|
10
|
+
<p>Look below at an example integration test.</p>
|
11
|
+
<pre>
|
12
|
+
require "minitest/autorun"
|
13
|
+
require "rack/test"
|
14
|
+
require "application"
|
15
|
+
<mark>require "snapshot_ui/test/minitest_helpers"</mark>
|
16
|
+
|
17
|
+
class ApplicationTest < Minitest::Spec
|
18
|
+
include Rack::Test::Methods
|
19
|
+
<mark>include SnapshotUI::Test::MinitestHelpers</mark>
|
20
|
+
|
21
|
+
def app
|
22
|
+
@app ||= Application.new
|
23
|
+
end
|
24
|
+
|
25
|
+
it "renders the root page" do
|
26
|
+
get "/"
|
27
|
+
|
28
|
+
<mark>take_snapshot last_response</mark>
|
29
|
+
|
30
|
+
_(last_response.body).must_match("To see a world in a grain of sand...")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
</pre>
|
34
|
+
|
35
|
+
<p>
|
36
|
+
To activate live updates for this page, execute the following command in your terminal.<br>
|
37
|
+
</p>
|
38
|
+
<pre>
|
39
|
+
bundle exec snapshot_ui watch SNAPSHOT_DIRECTORY</pre>
|
40
|
+
<p>
|
41
|
+
where <code>SNAPSHOT_DIRECTORY</code> is a path to a directory that contains
|
42
|
+
snapshot UI data, e.g. <code>tmp/snapshot_ui</code>.
|
43
|
+
</p>
|
44
|
+
<% end %>
|
45
|
+
|
46
|
+
<% @grouped_by_test_class.each do |test_group, snapshots| %>
|
47
|
+
<h2><%= test_group %></h2>
|
48
|
+
|
49
|
+
<ul>
|
50
|
+
<% snapshots.each do |snapshot| %>
|
51
|
+
<li>
|
52
|
+
<a href="<%= snapshot_path(snapshot.slug) %>">
|
53
|
+
<%= snapshot.context.name %>
|
54
|
+
<%= if snapshot.context.take_snapshot_index > 0 then "(##{(snapshot.context.take_snapshot_index + 1)} in the same test)" end %>
|
55
|
+
</a>
|
56
|
+
</li>
|
57
|
+
<% end %>
|
58
|
+
</ul>
|
59
|
+
<% end %>
|
60
|
+
</body>
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "erb"
|
4
|
+
require "rack"
|
5
|
+
require "rack/static"
|
6
|
+
require_relative "web/application"
|
7
|
+
|
8
|
+
module SnapshotUI
|
9
|
+
class Web
|
10
|
+
def self.call(env)
|
11
|
+
new.call(env)
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
app =
|
16
|
+
Rack::Builder.app do
|
17
|
+
use Rack::Static,
|
18
|
+
root: "#{File.dirname(__FILE__)}/web/assets",
|
19
|
+
urls: %w[/stylesheets /javascripts]
|
20
|
+
|
21
|
+
run Application.new
|
22
|
+
end
|
23
|
+
|
24
|
+
app.call(env)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/snapshot_ui.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "snapshot_ui/snapshot"
|
4
|
+
require_relative "snapshot_ui/configuration"
|
5
|
+
|
6
|
+
module SnapshotUI
|
7
|
+
DEFAULT_CONFIGURATION = {
|
8
|
+
project_root_directory: ".",
|
9
|
+
storage_directory: "tmp/snapshot_ui"
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
def self.configure
|
13
|
+
yield(configuration)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.configuration
|
17
|
+
@configuration ||= Configuration.new(**DEFAULT_CONFIGURATION)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.snapshot_taking_enabled?
|
21
|
+
%w[1 true].include?(ENV["TAKE_SNAPSHOTS"])
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.publish_snapshots_in_progress
|
25
|
+
Snapshot.publish_snapshots_in_progress
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.clear_snapshots_in_progress
|
29
|
+
Snapshot.clear_snapshots_in_progress
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.clear_snapshots
|
33
|
+
Snapshot.clear_snapshots
|
34
|
+
end
|
35
|
+
end
|
data/snapshot_ui.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/snapshot_ui/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "snapshot_ui"
|
7
|
+
spec.version = SnapshotUI::VERSION
|
8
|
+
spec.authors = ["Tomaz Zlender"]
|
9
|
+
spec.email = ["tomaz@zlender.se"]
|
10
|
+
|
11
|
+
spec.summary = "Take snapshots of UI during testing for inspection in a browser."
|
12
|
+
spec.homepage = "https://github.com/tomazzlender/snapshot_ui"
|
13
|
+
spec.license = "MIT"
|
14
|
+
spec.required_ruby_version = ">= 3.0.0"
|
15
|
+
|
16
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
17
|
+
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
19
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
20
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
21
|
+
|
22
|
+
spec.files = (`git ls-files | grep -E '^(lib)'`.split("\n") + %w[snapshot_ui.gemspec README.md CHANGELOG.md LICENSE.txt bin/snapshot_ui])
|
23
|
+
spec.executables = ["snapshot_ui"]
|
24
|
+
spec.require_paths = ["lib"]
|
25
|
+
|
26
|
+
# Uncomment to register a new dependency of your gem
|
27
|
+
spec.add_dependency "rack", "~> 3.0"
|
28
|
+
spec.add_dependency "listen", "~> 3.9"
|
29
|
+
spec.add_dependency "async-websocket", "~> 0.26"
|
30
|
+
spec.add_dependency "falcon", "~> 0.47"
|
31
|
+
spec.add_dependency "thor", "~> 1.3"
|
32
|
+
|
33
|
+
# For more information and examples about making a new gem, check out our
|
34
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
35
|
+
end
|
metadata
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: snapshot_ui
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tomaz Zlender
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-06-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: listen
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.9'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.9'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: async-websocket
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.26'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.26'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: falcon
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.47'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.47'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: thor
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.3'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.3'
|
83
|
+
description:
|
84
|
+
email:
|
85
|
+
- tomaz@zlender.se
|
86
|
+
executables:
|
87
|
+
- snapshot_ui
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- CHANGELOG.md
|
92
|
+
- LICENSE.txt
|
93
|
+
- README.md
|
94
|
+
- bin/snapshot_ui
|
95
|
+
- lib/snapshot_ui.rb
|
96
|
+
- lib/snapshot_ui/cli.rb
|
97
|
+
- lib/snapshot_ui/cli/watcher.ru
|
98
|
+
- lib/snapshot_ui/configuration.rb
|
99
|
+
- lib/snapshot_ui/snapshot.rb
|
100
|
+
- lib/snapshot_ui/snapshot/context.rb
|
101
|
+
- lib/snapshot_ui/snapshot/storage.rb
|
102
|
+
- lib/snapshot_ui/test/minitest_helpers.rb
|
103
|
+
- lib/snapshot_ui/version.rb
|
104
|
+
- lib/snapshot_ui/web.rb
|
105
|
+
- lib/snapshot_ui/web/application.rb
|
106
|
+
- lib/snapshot_ui/web/assets/javascripts/application.js
|
107
|
+
- lib/snapshot_ui/web/assets/javascripts/channels/consumer.js
|
108
|
+
- lib/snapshot_ui/web/assets/javascripts/channels/index.js
|
109
|
+
- lib/snapshot_ui/web/assets/javascripts/channels/refresh_channel.js
|
110
|
+
- lib/snapshot_ui/web/assets/javascripts/controllers/refresh_controller.js
|
111
|
+
- lib/snapshot_ui/web/assets/stylesheets/application.css
|
112
|
+
- lib/snapshot_ui/web/views/layout.html.erb
|
113
|
+
- lib/snapshot_ui/web/views/snapshots/index.html.erb
|
114
|
+
- lib/snapshot_ui/web/views/snapshots/not_found.html.erb
|
115
|
+
- lib/snapshot_ui/web/views/snapshots/show.html.erb
|
116
|
+
- snapshot_ui.gemspec
|
117
|
+
homepage: https://github.com/tomazzlender/snapshot_ui
|
118
|
+
licenses:
|
119
|
+
- MIT
|
120
|
+
metadata:
|
121
|
+
allowed_push_host: https://rubygems.org
|
122
|
+
homepage_uri: https://github.com/tomazzlender/snapshot_ui
|
123
|
+
source_code_uri: https://github.com/tomazzlender/snapshot_ui
|
124
|
+
changelog_uri: https://github.com/tomazzlender/snapshot_ui/blob/main/CHANGELOG.md
|
125
|
+
post_install_message:
|
126
|
+
rdoc_options: []
|
127
|
+
require_paths:
|
128
|
+
- lib
|
129
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - ">="
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: 3.0.0
|
134
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
requirements: []
|
140
|
+
rubygems_version: 3.4.19
|
141
|
+
signing_key:
|
142
|
+
specification_version: 4
|
143
|
+
summary: Take snapshots of UI during testing for inspection in a browser.
|
144
|
+
test_files: []
|