snapshot_ui 0.4.1 → 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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +145 -4
  4. data/lib/minitest/snapshot_ui_plugin.rb +9 -0
  5. data/lib/snapshot_ui/colorize.rb +15 -4
  6. data/lib/snapshot_ui/configuration.rb +12 -3
  7. data/lib/snapshot_ui/snapshot/context.rb +4 -1
  8. data/lib/snapshot_ui/snapshot/storage.rb +54 -3
  9. data/lib/snapshot_ui/snapshot.rb +54 -13
  10. data/lib/snapshot_ui/test/minitest_helpers.rb +21 -6
  11. data/lib/snapshot_ui/version.rb +1 -1
  12. data/lib/snapshot_ui/web/application.rb +100 -39
  13. data/lib/snapshot_ui/web/assets/javascripts/application.js +4 -2
  14. data/lib/snapshot_ui/web/assets/javascripts/controllers/refresh_controller.js +76 -12
  15. data/lib/snapshot_ui/web/assets/javascripts/controllers/source_location_controller.js +14 -6
  16. data/lib/snapshot_ui/web/assets/javascripts/vendor/stimulus.js +2564 -0
  17. data/lib/snapshot_ui/web/assets/javascripts/vendor/turbo.js +7257 -0
  18. data/lib/snapshot_ui/web/assets/stylesheets/application.css +120 -0
  19. data/lib/snapshot_ui/web/views/layout.html.erb +4 -9
  20. data/lib/snapshot_ui/web/views/snapshots/index.html.erb +5 -11
  21. data/lib/snapshot_ui/web.rb +27 -10
  22. data/lib/snapshot_ui.rb +4 -2
  23. data/snapshot_ui.gemspec +8 -14
  24. metadata +17 -76
  25. data/bin/snapshot_ui +0 -7
  26. data/lib/snapshot_ui/cli/watcher.ru +0 -91
  27. data/lib/snapshot_ui/cli.rb +0 -29
  28. data/lib/snapshot_ui/web/assets/javascripts/channels/consumer.js +0 -3
  29. data/lib/snapshot_ui/web/assets/javascripts/channels/index.js +0 -1
  30. 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
- 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
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
- render_raw_response_body(@snapshot.body)
22
+ render_raw(@snapshot)
24
23
  elsif (slug = parse_snapshot_path(@request.path_info))
25
24
  @snapshot = Snapshot.find(slug)
26
- render("snapshots/show", status: 200)
25
+ render_show(@snapshot)
27
26
  else
28
- render("snapshots/not_found", status: 200)
27
+ render("snapshots/not_found", status: 404)
29
28
  end
30
29
  rescue SnapshotUI::Snapshot::NotFound
31
- render("snapshots/not_found", status: 200)
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
- def file_event_stream(directory)
37
- message = '<turbo-stream action="refresh"></turbo-stream>'
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
- Enumerator.new do |stream|
40
- listener = Listen.to(directory) do |_modified, _added, _removed|
41
- stream << "data: #{message}\n\n"
42
- end
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
- 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"
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
- def render_raw_response_body(response_body)
56
- [200, {"content-type" => "text/html; charset=utf-8"}, [response_body]]
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
- [root_path, "stylesheets", stylesheet].join("/")
119
+ asset_path("stylesheets", stylesheet)
85
120
  end
86
121
 
87
- def javascript_path(stylesheet)
88
- [root_path, "javascripts", stylesheet].join("/")
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, "response", slug].join("/")
132
+ [root_path, slug].join("/")
93
133
  end
94
134
 
95
135
  def raw_snapshot_path(slug)
96
- [root_path, "response", "raw", slug].join("/")
136
+ [root_path, "raw", slug].join("/")
97
137
  end
98
138
 
99
139
  def parse_snapshot_path(path)
100
- pattern = %r{^/response/(?<slug>.+)$}
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{^/response/raw/(?<slug>.+)$}
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
- 'data-controller="refresh" data-action="refresh-connected@window->refresh#connected refresh-disconnected@window->refresh#disconnected turbo:before-render@window->refresh#display_status"'
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
- title = snapshot.context.name.sub("test_", "").gsub(/^\d{4}\s*/, "").tr("_", " ")
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 snapshot.context.take_snapshot_index > 0
127
- " (##{snapshot.context.take_snapshot_index + 1} in the same test)"
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
- import { Application } from "@hotwired/stimulus"
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 "channels"
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.display_status()
19
+ this.online = false
20
+ this.displayStatus()
21
+ this.timer = setInterval(() => this.check(), this.intervalValue)
22
+ this.check()
6
23
  }
7
24
 
8
- connected(_event) {
9
- window.refresh_status = "connected"
10
- this.display_status()
25
+ disconnect() {
26
+ clearInterval(this.timer)
11
27
  }
12
28
 
13
- disconnected(_event) {
14
- window.refresh_status = undefined
15
- this.display_status()
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
- display_status() {
19
- if (window.refresh_status === "connected") {
20
- document.title = "Snapshot UI - ✅ Connected"
21
- } else {
22
- document.title = "Snapshot UI - ⏳ Reconnecting..."
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
- let currentTarget = event.currentTarget
7
+ if (window.isSecureContext) {
8
+ let currentTarget = event.currentTarget
8
9
 
9
- navigator.clipboard.writeText(currentTarget.dataset.sourceLocation)
10
+ navigator.clipboard.writeText(currentTarget.dataset.sourceLocation)
10
11
 
11
- currentTarget.classList.toggle('animate');
12
- setTimeout(()=> {
13
- currentTarget.classList.toggle('animate')
14
- },300)
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
  }