snapshot_ui 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []