snapshot_ui 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +145 -4
- data/lib/minitest/snapshot_ui_plugin.rb +9 -0
- data/lib/snapshot_ui/colorize.rb +15 -4
- data/lib/snapshot_ui/configuration.rb +12 -3
- data/lib/snapshot_ui/snapshot/context.rb +4 -1
- data/lib/snapshot_ui/snapshot/storage.rb +54 -3
- data/lib/snapshot_ui/snapshot.rb +54 -13
- data/lib/snapshot_ui/test/minitest_helpers.rb +21 -6
- data/lib/snapshot_ui/version.rb +1 -1
- data/lib/snapshot_ui/web/application.rb +100 -39
- data/lib/snapshot_ui/web/assets/javascripts/application.js +4 -2
- data/lib/snapshot_ui/web/assets/javascripts/controllers/refresh_controller.js +76 -12
- data/lib/snapshot_ui/web/assets/javascripts/controllers/source_location_controller.js +14 -6
- data/lib/snapshot_ui/web/assets/javascripts/vendor/stimulus.js +2564 -0
- data/lib/snapshot_ui/web/assets/javascripts/vendor/turbo.js +7257 -0
- data/lib/snapshot_ui/web/assets/stylesheets/application.css +121 -0
- data/lib/snapshot_ui/web/views/layout.html.erb +4 -9
- data/lib/snapshot_ui/web/views/snapshots/index.html.erb +5 -11
- data/lib/snapshot_ui/web.rb +27 -10
- data/lib/snapshot_ui.rb +4 -2
- data/snapshot_ui.gemspec +8 -14
- metadata +17 -76
- data/bin/snapshot_ui +0 -7
- data/lib/snapshot_ui/cli/watcher.ru +0 -91
- data/lib/snapshot_ui/cli.rb +0 -29
- data/lib/snapshot_ui/web/assets/javascripts/channels/consumer.js +0 -3
- data/lib/snapshot_ui/web/assets/javascripts/channels/index.js +0 -1
- data/lib/snapshot_ui/web/assets/javascripts/channels/refresh_channel.js +0 -27
|
@@ -1,59 +1,90 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "erb"
|
|
4
|
-
require "rack/static"
|
|
5
4
|
require_relative "../snapshot"
|
|
6
|
-
require "listen"
|
|
7
5
|
|
|
8
6
|
module SnapshotUI
|
|
9
7
|
class Web
|
|
10
8
|
class Application
|
|
11
9
|
def call(env)
|
|
12
10
|
@request = Rack::Request.new(env)
|
|
11
|
+
# Read the version before anything else, so that a publish happening while
|
|
12
|
+
# the page renders is noticed by the next poll instead of being missed.
|
|
13
|
+
@version = Snapshot.version
|
|
13
14
|
|
|
14
15
|
if parse_root_path(@request.path_info)
|
|
15
16
|
@grouped_by_test_class = SnapshotUI::Snapshot.grouped_by_test_case
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
[200, {"content-type" => "text/event-stream", "cache-control" => "no-cache", "connection" => "keep-alive"}, file_event_stream(SnapshotUI.configuration.storage_directory)]
|
|
20
|
-
end
|
|
17
|
+
render("snapshots/index", status: 200)
|
|
18
|
+
elsif parse_version_path(@request.path_info)
|
|
19
|
+
render_version
|
|
21
20
|
elsif (slug = parse_raw_snapshot_path(@request.path_info))
|
|
22
21
|
@snapshot = Snapshot.find(slug)
|
|
23
|
-
|
|
22
|
+
render_raw(@snapshot)
|
|
24
23
|
elsif (slug = parse_snapshot_path(@request.path_info))
|
|
25
24
|
@snapshot = Snapshot.find(slug)
|
|
26
|
-
|
|
25
|
+
render_show(@snapshot)
|
|
27
26
|
else
|
|
28
|
-
render("snapshots/not_found", status:
|
|
27
|
+
render("snapshots/not_found", status: 404)
|
|
29
28
|
end
|
|
30
29
|
rescue SnapshotUI::Snapshot::NotFound
|
|
31
|
-
render("snapshots/not_found", status:
|
|
30
|
+
render("snapshots/not_found", status: 404)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Exposed for registered renderers (see SnapshotUI::Web.register_renderer).
|
|
34
|
+
attr_reader :request, :version
|
|
35
|
+
|
|
36
|
+
# Wraps already-rendered page HTML (a <body>…) in the shared layout and
|
|
37
|
+
# returns a Rack response triple. Registered renderers use this so their
|
|
38
|
+
# pages get the same chrome and live-refresh behaviour as the built-in
|
|
39
|
+
# ones. The layout calls +refresh_controller+, so pass the view (self) as
|
|
40
|
+
# the layout's binding context.
|
|
41
|
+
def render_body_in_layout(body_html, status: 200)
|
|
42
|
+
response_body = ERB.new(read_template("layout")).result(get_binding { body_html })
|
|
43
|
+
[status, {"content-type" => "text/html; charset=utf-8"}, [response_body]]
|
|
32
44
|
end
|
|
33
45
|
|
|
34
46
|
private
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
# Polled by the browser (assets/javascripts/controllers/refresh_controller.js)
|
|
49
|
+
# to find out when the published snapshots change. Answers 304 Not Modified while the
|
|
50
|
+
# client's version is still current.
|
|
51
|
+
def render_version
|
|
52
|
+
etag = %("#{@version}")
|
|
53
|
+
headers = {"etag" => etag, "cache-control" => "no-cache"}
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
if @request.get_header("HTTP_IF_NONE_MATCH") == etag
|
|
56
|
+
[304, headers, []]
|
|
57
|
+
else
|
|
58
|
+
[200, headers.merge("content-type" => "text/plain; charset=utf-8"), [@version]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
43
61
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
# The snapshot's page. Responses show the raw body in an iframe; other
|
|
63
|
+
# types delegate to their registered renderer's #show.
|
|
64
|
+
def render_show(snapshot)
|
|
65
|
+
if snapshot.response?
|
|
66
|
+
render("snapshots/show", status: 200)
|
|
67
|
+
else
|
|
68
|
+
status, headers, body = renderer_for(snapshot).show(snapshot, self)
|
|
69
|
+
[status, headers, body]
|
|
52
70
|
end
|
|
53
71
|
end
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
# The raw endpoint. Responses serve the stored body as HTML; other types
|
|
74
|
+
# delegate to their registered renderer's #raw (which may honour params
|
|
75
|
+
# such as which email part to serve).
|
|
76
|
+
def render_raw(snapshot)
|
|
77
|
+
if snapshot.response?
|
|
78
|
+
[200, {"content-type" => "text/html; charset=utf-8"}, [snapshot.body]]
|
|
79
|
+
else
|
|
80
|
+
status, headers, body = renderer_for(snapshot).raw(snapshot, self)
|
|
81
|
+
[status, headers, body]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def renderer_for(snapshot)
|
|
86
|
+
SnapshotUI::Web.renderer(snapshot.type) ||
|
|
87
|
+
raise(SnapshotUI::Snapshot::NotFound, "No renderer registered for snapshot type `#{snapshot.type}`.")
|
|
57
88
|
end
|
|
58
89
|
|
|
59
90
|
def render(template, status:)
|
|
@@ -64,7 +95,7 @@ module SnapshotUI
|
|
|
64
95
|
[status, response_headers, [response_body]]
|
|
65
96
|
end
|
|
66
97
|
|
|
67
|
-
def get_binding
|
|
98
|
+
def get_binding(&_block)
|
|
68
99
|
binding
|
|
69
100
|
end
|
|
70
101
|
|
|
@@ -80,24 +111,33 @@ module SnapshotUI
|
|
|
80
111
|
@request.env["SCRIPT_NAME"]
|
|
81
112
|
end
|
|
82
113
|
|
|
114
|
+
def version_path
|
|
115
|
+
[root_path, "version"].join("/")
|
|
116
|
+
end
|
|
117
|
+
|
|
83
118
|
def stylesheet_path(stylesheet)
|
|
84
|
-
|
|
119
|
+
asset_path("stylesheets", stylesheet)
|
|
85
120
|
end
|
|
86
121
|
|
|
87
|
-
def javascript_path(
|
|
88
|
-
|
|
122
|
+
def javascript_path(javascript)
|
|
123
|
+
asset_path("javascripts", javascript)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Versioned, so that browsers don't keep using the assets of a previous release.
|
|
127
|
+
def asset_path(directory, file)
|
|
128
|
+
"#{[root_path, directory, file].join("/")}?v=#{SnapshotUI::VERSION}"
|
|
89
129
|
end
|
|
90
130
|
|
|
91
131
|
def snapshot_path(slug)
|
|
92
|
-
[root_path,
|
|
132
|
+
[root_path, slug].join("/")
|
|
93
133
|
end
|
|
94
134
|
|
|
95
135
|
def raw_snapshot_path(slug)
|
|
96
|
-
[root_path, "
|
|
136
|
+
[root_path, "raw", slug].join("/")
|
|
97
137
|
end
|
|
98
138
|
|
|
99
139
|
def parse_snapshot_path(path)
|
|
100
|
-
pattern = %r{^/
|
|
140
|
+
pattern = %r{^/(?<slug>.+)$}
|
|
101
141
|
|
|
102
142
|
if (match = pattern.match(path))
|
|
103
143
|
match[:slug]
|
|
@@ -105,7 +145,7 @@ module SnapshotUI
|
|
|
105
145
|
end
|
|
106
146
|
|
|
107
147
|
def parse_raw_snapshot_path(path)
|
|
108
|
-
pattern = %r{^/
|
|
148
|
+
pattern = %r{^/raw/(?<slug>.+)$}
|
|
109
149
|
|
|
110
150
|
if (match = pattern.match(path))
|
|
111
151
|
match[:slug]
|
|
@@ -116,15 +156,33 @@ module SnapshotUI
|
|
|
116
156
|
path == "" || path == "/"
|
|
117
157
|
end
|
|
118
158
|
|
|
159
|
+
def parse_version_path(path)
|
|
160
|
+
path == "/version"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Wires <body> up to the Stimulus controller that keeps the page in sync with
|
|
164
|
+
# the published snapshots (assets/javascripts/controllers/refresh_controller.js).
|
|
119
165
|
def refresh_controller
|
|
120
|
-
|
|
166
|
+
%(data-controller="refresh" data-refresh-url-value="#{version_path}" data-refresh-version-value="#{@version}" ) +
|
|
167
|
+
%(data-action="turbo:render@window->refresh#displayStatus visibilitychange@document->refresh#check")
|
|
121
168
|
end
|
|
122
169
|
|
|
123
170
|
def snapshot_title(snapshot)
|
|
124
|
-
|
|
171
|
+
snapshot.context.metadata[:title] || generic_snapshot_title(snapshot.context)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A small label shown next to non-response snapshots in the list (e.g. mail).
|
|
175
|
+
def snapshot_type_badge(snapshot)
|
|
176
|
+
return "" if snapshot.response?
|
|
177
|
+
|
|
178
|
+
%(<span class="type_badge type_badge_#{snapshot.type}">#{snapshot.type}</span>)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def generic_snapshot_title(context)
|
|
182
|
+
title = context.name.sub("test_", "").gsub(/^\d{4}\s*/, "").tr("_", " ")
|
|
125
183
|
suffix =
|
|
126
|
-
if
|
|
127
|
-
" (##{
|
|
184
|
+
if context.take_snapshot_index > 0
|
|
185
|
+
" (##{context.take_snapshot_index + 1} in the same test)"
|
|
128
186
|
end
|
|
129
187
|
|
|
130
188
|
"#{title}#{suffix}"
|
|
@@ -146,6 +204,9 @@ module SnapshotUI
|
|
|
146
204
|
</svg>
|
|
147
205
|
HTML
|
|
148
206
|
end
|
|
207
|
+
|
|
208
|
+
# URL helpers registered renderers rely on (defined above under private).
|
|
209
|
+
public :root_path, :snapshot_path, :raw_snapshot_path
|
|
149
210
|
end
|
|
150
211
|
end
|
|
151
212
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// Turbo and Stimulus are bundled with the gem (see vendor/), so the UI works
|
|
2
|
+
// without internet access. Their versions are noted in the headers of the
|
|
3
|
+
// vendored files.
|
|
2
4
|
import "@hotwired/turbo"
|
|
3
|
-
import "
|
|
5
|
+
import { Application } from "@hotwired/stimulus"
|
|
4
6
|
|
|
5
7
|
import RefreshController from "controllers/refresh_controller"
|
|
6
8
|
import SourceLocationController from "controllers/source_location_controller"
|
|
@@ -1,25 +1,89 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import * as Turbo from "@hotwired/turbo"
|
|
2
3
|
|
|
4
|
+
// Keeps the page in sync with the published snapshots.
|
|
5
|
+
//
|
|
6
|
+
// Every page is rendered with the version of the published snapshots it shows
|
|
7
|
+
// (`data-refresh-version-value` on <body>). This controller polls the version
|
|
8
|
+
// endpoint and, as soon as the version changes, refreshes the page through
|
|
9
|
+
// Turbo, which morphs the new content in and preserves the scroll position.
|
|
10
|
+
// The tab title shows whether polling is working.
|
|
3
11
|
export default class extends Controller {
|
|
12
|
+
static values = {
|
|
13
|
+
url: String,
|
|
14
|
+
version: String,
|
|
15
|
+
interval: { type: Number, default: 1000 }
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
connect() {
|
|
5
|
-
this.
|
|
19
|
+
this.online = false
|
|
20
|
+
this.displayStatus()
|
|
21
|
+
this.timer = setInterval(() => this.check(), this.intervalValue)
|
|
22
|
+
this.check()
|
|
6
23
|
}
|
|
7
24
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
this.display_status()
|
|
25
|
+
disconnect() {
|
|
26
|
+
clearInterval(this.timer)
|
|
11
27
|
}
|
|
12
28
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
// Called by Stimulus once the refreshed page, carrying the new version, has
|
|
30
|
+
// been rendered. Polling then continues with that version.
|
|
31
|
+
versionValueChanged() {
|
|
32
|
+
this.refreshing = false
|
|
16
33
|
}
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
// Polling pauses while the tab is hidden, so catch up as soon as it is visible
|
|
36
|
+
// again (wired to `visibilitychange`).
|
|
37
|
+
async check() {
|
|
38
|
+
if (this.checking || this.refreshing || document.hidden || this.previewing) return
|
|
39
|
+
this.checking = true
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(this.urlValue, {
|
|
43
|
+
cache: "no-store",
|
|
44
|
+
headers: { "If-None-Match": `"${this.versionValue}"` }
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (response.status === 304) {
|
|
48
|
+
this.setOnline(true)
|
|
49
|
+
} else if (response.ok) {
|
|
50
|
+
this.setOnline(true)
|
|
51
|
+
if ((await response.text()) !== this.versionValue) this.refresh()
|
|
52
|
+
} else {
|
|
53
|
+
this.setOnline(false)
|
|
54
|
+
}
|
|
55
|
+
} catch (_error) {
|
|
56
|
+
this.setOnline(false)
|
|
57
|
+
} finally {
|
|
58
|
+
this.checking = false
|
|
23
59
|
}
|
|
24
60
|
}
|
|
61
|
+
|
|
62
|
+
refresh() {
|
|
63
|
+
this.refreshing = true
|
|
64
|
+
// Should the refresh not go through (e.g. the app went away half-way), resume polling after a while.
|
|
65
|
+
setTimeout(() => { this.refreshing = false }, 10 * this.intervalValue)
|
|
66
|
+
|
|
67
|
+
Turbo.visit(location.href, { action: "replace" })
|
|
68
|
+
// Morphing keeps the iframe as it is, so reload the snapshot it shows explicitly.
|
|
69
|
+
document.querySelector("iframe#raw")?.contentWindow.location.reload()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setOnline(online) {
|
|
73
|
+
if (this.online === online) return
|
|
74
|
+
|
|
75
|
+
this.online = online
|
|
76
|
+
this.displayStatus()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Turbo resets the title whenever it renders a page, so this also runs on `turbo:render`.
|
|
80
|
+
displayStatus() {
|
|
81
|
+
const title = document.title.replace(/ - (✅|⏳).*$/, "")
|
|
82
|
+
document.title = `${title} - ${this.online ? "✅ Connected" : "⏳ Reconnecting..."}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Turbo may show a cached copy of a page while loading it; leave that alone.
|
|
86
|
+
get previewing() {
|
|
87
|
+
return document.documentElement.hasAttribute("data-turbo-preview")
|
|
88
|
+
}
|
|
25
89
|
}
|
|
@@ -4,14 +4,22 @@ export default class extends Controller {
|
|
|
4
4
|
copy(event) {
|
|
5
5
|
event.preventDefault()
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
if (window.isSecureContext) {
|
|
8
|
+
let currentTarget = event.currentTarget
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
navigator.clipboard.writeText(currentTarget.dataset.sourceLocation)
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
currentTarget.classList.toggle('animate');
|
|
13
|
+
setTimeout(()=> {
|
|
14
|
+
currentTarget.classList.toggle('animate')
|
|
15
|
+
},300)
|
|
16
|
+
} else {
|
|
17
|
+
let message =
|
|
18
|
+
`Copying to the clipboard isn't possible in an insecure browser context.\n\n` +
|
|
19
|
+
`You are accessing this page from a hostname ${window.location.hostname} which is considered insecure.\n\n` +
|
|
20
|
+
`Changing the hostname will most likely resolve the issue.`
|
|
15
21
|
|
|
22
|
+
alert(message)
|
|
23
|
+
}
|
|
16
24
|
}
|
|
17
25
|
}
|