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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-06-09
4
+
5
+ - Initial release
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,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), '../lib'))
4
+
5
+ require "snapshot_ui/cli"
6
+
7
+ SnapshotUI::CLI.start(ARGV)
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapshotUI
4
+ VERSION = "0.1.0"
5
+ 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,8 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import "@hotwired/turbo"
3
+ import "channels"
4
+
5
+ import RefreshController from "controllers/refresh_controller"
6
+
7
+ window.Stimulus = Application.start()
8
+ Stimulus.register("refresh", RefreshController)
@@ -0,0 +1,3 @@
1
+ import { createConsumer } from "@rails/actioncable"
2
+
3
+ export default createConsumer()
@@ -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,8 @@
1
+ <body id="not_found" <%= refresh_controller %>>
2
+ <h1>Not Found</h1>
3
+
4
+ <p>
5
+ The snapshot you are looking for can't be found.
6
+ Go back to <a href="<%= root_path %>" style="text-decoration: underline; color: #666">a list of snapshots</a>.
7
+ </p>
8
+ </body>
@@ -0,0 +1,3 @@
1
+ <body id="snapshots_show" <%= refresh_controller %>>
2
+ <iframe id="raw" src="<%= raw_snapshot_path(@snapshot.slug) %>"></iframe>
3
+ </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
@@ -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
@@ -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: []