trmnl_preview 0.16.0 → 0.17.1
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 +15 -0
- data/README.md +37 -0
- data/lib/trmnlp/browser_pool.rb +8 -8
- data/lib/trmnlp/cli.rb +7 -0
- data/lib/trmnlp/commands/test.rb +25 -0
- data/lib/trmnlp/config/project.rb +9 -6
- data/lib/trmnlp/config.rb +2 -2
- data/lib/trmnlp/context.rb +12 -4
- data/lib/trmnlp/errors.rb +1 -0
- data/lib/trmnlp/paths.rb +4 -2
- data/lib/trmnlp/poller.rb +20 -6
- data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
- data/lib/trmnlp/renderer.rb +4 -1
- data/lib/trmnlp/screenshot.rb +23 -9
- data/lib/trmnlp/testing/browser.rb +87 -0
- data/lib/trmnlp/testing/certificate_authority.rb +84 -0
- data/lib/trmnlp/testing/device_models.rb +80 -0
- data/lib/trmnlp/testing/frozen_clock.rb +86 -0
- data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
- data/lib/trmnlp/testing/memory_sampler.rb +44 -0
- data/lib/trmnlp/testing/mock_proxy.rb +124 -0
- data/lib/trmnlp/testing/mock_table.rb +102 -0
- data/lib/trmnlp/testing/node_version.rb +27 -0
- data/lib/trmnlp/testing/plugin.rb +91 -0
- data/lib/trmnlp/testing/report.html.erb +79 -0
- data/lib/trmnlp/testing/report.rb +91 -0
- data/lib/trmnlp/testing/rspec.rb +89 -0
- data/lib/trmnlp/testing/run.rb +150 -0
- data/lib/trmnlp/testing/screen.rb +83 -0
- data/lib/trmnlp/testing/snapshot.rb +55 -0
- data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
- data/lib/trmnlp/transform_pipeline.rb +5 -4
- data/lib/trmnlp/transform_state.rb +4 -1
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp.rb +1 -0
- data/templates/init/.github/workflows/trmnl.yml +33 -2
- data/templates/init/.gitignore +3 -0
- data/templates/init/tests/plugin_spec.rb +32 -0
- data/trmnl_preview.gemspec +5 -0
- data/web/views/render_html.erb +1 -1
- metadata +62 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative '../errors'
|
|
7
|
+
|
|
8
|
+
module TRMNLP
|
|
9
|
+
module Testing
|
|
10
|
+
# TRMNL's device models, as `trmnlp serve`'s picker loads them, turned into what a render needs.
|
|
11
|
+
module DeviceModels
|
|
12
|
+
API_URL = 'https://trmnl.com/api'
|
|
13
|
+
|
|
14
|
+
Device = Data.define(:name, :width, :height, :bit_depth, :screen_classes, :image_size_limit) do
|
|
15
|
+
def render_params = { width:, height:, model: name, bit_depth: }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def names = models.map { it['name'] }
|
|
21
|
+
|
|
22
|
+
# A model name, or a Hash of width:, height:, bit_depth: and optionally screen_classes: for a custom screen.
|
|
23
|
+
def find(device, orientation: :landscape, palette: nil)
|
|
24
|
+
return custom(device) if device.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
model = models.find { it['name'] == device.to_s } ||
|
|
27
|
+
raise(TestingError, "Unknown device #{device.inspect}; known: #{names.join(', ')}")
|
|
28
|
+
portrait = orientation.to_s == 'portrait'
|
|
29
|
+
width, height = portrait ? model.values_at('height', 'width') : model.values_at('width', 'height')
|
|
30
|
+
chosen = palette_for(model, palette)
|
|
31
|
+
Device.new(name: model['name'], width:, height:, bit_depth: bit_depth(model, chosen),
|
|
32
|
+
screen_classes: screen_classes(model, chosen, portrait), image_size_limit: model['image_size_limit'])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The picker's classes: palette, device, size, then portrait.
|
|
36
|
+
def screen_classes(model, palette, portrait)
|
|
37
|
+
css = model.dig('css', 'classes') || {}
|
|
38
|
+
['screen', palette&.fetch('framework_class'), css['device'], css['size'],
|
|
39
|
+
('screen--portrait' if portrait)].compact.join(' ')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def palette_for(model, id)
|
|
43
|
+
candidates = palettes.select { model['palette_ids'].to_a.include?(it['id']) }
|
|
44
|
+
return candidates.find { it['id'] == id.to_s } || raise(TestingError, unknown_palette(model, id)) if id
|
|
45
|
+
|
|
46
|
+
candidates.find { it['grays'] == 2**model['bit_depth'] } || candidates.first
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A palette of fewer grays draws the PNG at that depth, as the device then does.
|
|
50
|
+
def bit_depth(model, palette)
|
|
51
|
+
grays = palette && palette['grays']
|
|
52
|
+
grays ? [Math.log2(grays).ceil, model['bit_depth']].min : model['bit_depth']
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unknown_palette(model, id)
|
|
56
|
+
"Model #{model['name']} has no palette #{id.inspect}; it has #{model['palette_ids'].to_a.join(', ')}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def custom(device)
|
|
60
|
+
device = device.transform_keys(&:to_sym)
|
|
61
|
+
Device.new(name: device.fetch(:model, 'custom'), width: device.fetch(:width), height: device.fetch(:height),
|
|
62
|
+
bit_depth: device.fetch(:bit_depth, 1), screen_classes: device[:screen_classes],
|
|
63
|
+
image_size_limit: device[:image_size_limit])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def models = @models ||= fetch('models')
|
|
67
|
+
def palettes = @palettes ||= fetch('palettes')
|
|
68
|
+
|
|
69
|
+
def fetch(resource)
|
|
70
|
+
response = Faraday.get("#{API_URL}/#{resource}")
|
|
71
|
+
raise TestingError, "Could not load TRMNL's #{resource} (HTTP #{response.status})" unless response.success?
|
|
72
|
+
|
|
73
|
+
body = JSON.parse(response.body)
|
|
74
|
+
body.is_a?(Hash) ? body.fetch('data') : body
|
|
75
|
+
rescue Faraday::Error => e
|
|
76
|
+
raise TestingError, "Could not load TRMNL's #{resource}: #{e.message}. Pass a device Hash to test offline."
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
require_relative '../errors'
|
|
6
|
+
|
|
7
|
+
module TRMNLP
|
|
8
|
+
module Testing
|
|
9
|
+
# Starts every clock a run reads at the test's time: Ruby's own (Liquid's `now`, the trmnl namespace)
|
|
10
|
+
# and, through libfaketime, the transform process's, whatever its language.
|
|
11
|
+
module FrozenClock
|
|
12
|
+
LIBRARY_ENV_KEY = 'TRMNLP_LIBFAKETIME'
|
|
13
|
+
LIBRARY_GLOBS = {
|
|
14
|
+
darwin: %w[/opt/homebrew/opt/libfaketime/lib/faketime/libfaketime.1.dylib
|
|
15
|
+
/usr/local/opt/libfaketime/lib/faketime/libfaketime.1.dylib],
|
|
16
|
+
linux: %w[/usr/lib/*/faketime/libfaketime.so.1 /usr/lib/faketime/libfaketime.so.1
|
|
17
|
+
/usr/local/lib/faketime/libfaketime.so.1]
|
|
18
|
+
}.freeze
|
|
19
|
+
INSTALL_HINTS = { darwin: 'brew install libfaketime', linux: 'apt-get install libfaketime' }.freeze
|
|
20
|
+
# macOS drops DYLD_* variables for the binaries System Integrity Protection guards.
|
|
21
|
+
PROTECTED_PREFIXES = %w[/usr/bin/ /bin/ /sbin/ /usr/sbin/ /System/].freeze
|
|
22
|
+
|
|
23
|
+
# Answers Time.now with the frozen time on the current thread while it is set.
|
|
24
|
+
module FrozenNow
|
|
25
|
+
def now(...) = Thread.current[:trmnlp_frozen_now]&.dup || super
|
|
26
|
+
end
|
|
27
|
+
Time.singleton_class.prepend(FrozenNow)
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def around(time)
|
|
32
|
+
previous = Thread.current[:trmnlp_frozen_now]
|
|
33
|
+
Thread.current[:trmnlp_frozen_now] = time
|
|
34
|
+
yield
|
|
35
|
+
ensure
|
|
36
|
+
Thread.current[:trmnlp_frozen_now] = previous
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The clock reads its time from clock_file on every call, so #move can set it forward mid-run.
|
|
40
|
+
def environment(time, interpreter:, clock_file:)
|
|
41
|
+
os = operating_system
|
|
42
|
+
library = library_path(os)
|
|
43
|
+
refuse_protected(interpreter) if os == :darwin
|
|
44
|
+
|
|
45
|
+
write(clock_file, time)
|
|
46
|
+
preload_variables(os, library).merge('FAKETIME_TIMESTAMP_FILE' => clock_file.to_s, 'FAKETIME_NO_CACHE' => '1',
|
|
47
|
+
'FAKETIME_DONT_FAKE_MONOTONIC' => '1', 'FAKETIME_DONT_RESET' => '1',
|
|
48
|
+
'TZ' => 'UTC')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write(clock_file, time) = File.write(clock_file, "#{time.utc.strftime('@%Y-%m-%d %H:%M:%S')}\n")
|
|
52
|
+
|
|
53
|
+
def preload_variables(os, library)
|
|
54
|
+
return { 'LD_PRELOAD' => library } if os == :linux
|
|
55
|
+
|
|
56
|
+
{ 'DYLD_INSERT_LIBRARIES' => library, 'DYLD_FORCE_FLAT_NAMESPACE' => '1' }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def operating_system
|
|
60
|
+
case RbConfig::CONFIG['host_os']
|
|
61
|
+
when /darwin/ then :darwin
|
|
62
|
+
when /linux/ then :linux
|
|
63
|
+
else raise TestingError, 'A frozen clock needs libfaketime, which runs on macOS and Linux only'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def library_path(os)
|
|
68
|
+
return ENV[LIBRARY_ENV_KEY] if ENV[LIBRARY_ENV_KEY]
|
|
69
|
+
|
|
70
|
+
found = LIBRARY_GLOBS.fetch(os).flat_map { Dir.glob(it) }.first
|
|
71
|
+
return found if found
|
|
72
|
+
|
|
73
|
+
raise TestingError,
|
|
74
|
+
"A frozen clock needs libfaketime: #{INSTALL_HINTS.fetch(os)} (or set #{LIBRARY_ENV_KEY})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def refuse_protected(interpreter)
|
|
78
|
+
real_path = File.realpath(interpreter)
|
|
79
|
+
return unless PROTECTED_PREFIXES.any? { real_path.start_with?(it) }
|
|
80
|
+
|
|
81
|
+
raise TestingError,
|
|
82
|
+
"macOS will not freeze the clock for #{real_path}; install the interpreter with brew or mise"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module TRMNLP
|
|
7
|
+
module Testing
|
|
8
|
+
# Puts a page's linked stylesheets in the page itself. A page written with document.write, as TRMNL and
|
|
9
|
+
# trmnlp write theirs, runs its scripts without waiting for linked stylesheets, so a script that
|
|
10
|
+
# measures text (a layout solver, say) raced them and drew a different board from run to run.
|
|
11
|
+
module InlineStylesheets
|
|
12
|
+
LINK = /<link\b[^>]*\brel=["']stylesheet["'][^>]*>/i
|
|
13
|
+
HREF = /\bhref=["'](https?:[^"']+)["']/i
|
|
14
|
+
CSS_URL = /url\(\s*(['"]?)(?!data:|https?:|#)([^'")]+)\1\s*\)/i
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(html)
|
|
19
|
+
html.gsub(LINK) do |link|
|
|
20
|
+
url = link[HREF, 1]
|
|
21
|
+
css = url && stylesheet(url)
|
|
22
|
+
css ? "<style data-href=\"#{url}\">#{css}</style>" : link
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# nil when the stylesheet cannot be fetched: the page keeps its link.
|
|
27
|
+
def stylesheet(url)
|
|
28
|
+
cache.fetch(url) do
|
|
29
|
+
response = Faraday.get(url)
|
|
30
|
+
cache[url] = response.success? ? absolute_urls(response.body, url) : nil
|
|
31
|
+
end
|
|
32
|
+
rescue Faraday::Error
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def absolute_urls(css, base) = css.gsub(CSS_URL) { "url(\"#{URI.join(base, Regexp.last_match(2))}\")" }
|
|
37
|
+
|
|
38
|
+
def cache = @cache ||= {}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
module Testing
|
|
5
|
+
# The peak memory of a running process, read every few milliseconds until it exits: VmHWM on Linux
|
|
6
|
+
# (the kernel's own high-water mark), the resident size from ps on macOS (the highest one seen).
|
|
7
|
+
class MemorySampler
|
|
8
|
+
INTERVAL_SECONDS = 0.01
|
|
9
|
+
|
|
10
|
+
def self.start(pid) = new(pid).tap(&:start)
|
|
11
|
+
|
|
12
|
+
def initialize(pid)
|
|
13
|
+
@pid = pid
|
|
14
|
+
@peak_kb = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def start
|
|
18
|
+
@thread = Thread.new do
|
|
19
|
+
while (kb = read_kb)
|
|
20
|
+
@peak_kb = [@peak_kb.to_i, kb].max
|
|
21
|
+
sleep INTERVAL_SECONDS
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def peak_mb
|
|
27
|
+
@thread&.join
|
|
28
|
+
@peak_kb && (@peak_kb / 1024.0).round(1)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# nil once the process is gone.
|
|
34
|
+
def read_kb
|
|
35
|
+
return File.read("/proc/#{@pid}/status")[/VmHWM:\s+(\d+)/, 1]&.to_i if File.exist?('/proc/self/status')
|
|
36
|
+
|
|
37
|
+
kb = IO.popen(['ps', '-o', 'rss=', '-p', @pid.to_s], err: File::NULL, &:read).strip
|
|
38
|
+
kb.empty? ? nil : kb.to_i
|
|
39
|
+
rescue SystemCallError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
module TRMNLP
|
|
7
|
+
module Testing
|
|
8
|
+
# The HTTP(S) proxy a transform process runs behind, answering from a MockTable. HTTPS is opened with
|
|
9
|
+
# certificates from CertificateAuthority, so mocks work for every language and library that honors
|
|
10
|
+
# the proxy variables. One request per connection, then the connection closes.
|
|
11
|
+
class MockProxy
|
|
12
|
+
def initialize(table:, authority:)
|
|
13
|
+
@table = table
|
|
14
|
+
@authority = authority
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.open(table:, authority:)
|
|
18
|
+
proxy = new(table:, authority:).start
|
|
19
|
+
yield proxy
|
|
20
|
+
ensure
|
|
21
|
+
proxy&.stop
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start
|
|
25
|
+
@server = TCPServer.new('127.0.0.1', 0)
|
|
26
|
+
@thread = Thread.new { loop { Thread.new(@server.accept) { serve(it) } } }
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stop
|
|
31
|
+
@thread&.kill
|
|
32
|
+
@server&.close
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def environment
|
|
36
|
+
proxy = "http://127.0.0.1:#{@server.addr[1]}"
|
|
37
|
+
{ 'HTTP_PROXY' => proxy, 'HTTPS_PROXY' => proxy, 'http_proxy' => proxy, 'https_proxy' => proxy,
|
|
38
|
+
'NO_PROXY' => '', 'no_proxy' => '', 'NODE_USE_ENV_PROXY' => '1' }.merge(@authority.environment)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def serve(socket)
|
|
44
|
+
verb, target = socket.gets.to_s.split
|
|
45
|
+
return socket.close unless verb
|
|
46
|
+
|
|
47
|
+
verb == 'CONNECT' ? serve_tunnel(socket, target) : serve_request(socket, verb, target, read_headers(socket))
|
|
48
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
49
|
+
nil
|
|
50
|
+
ensure
|
|
51
|
+
socket.close unless socket.closed?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def serve_tunnel(socket, target)
|
|
55
|
+
read_headers(socket)
|
|
56
|
+
host, port = target.split(':')
|
|
57
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
58
|
+
tls = OpenSSL::SSL::SSLSocket.new(socket, @authority.ssl_context_for(host))
|
|
59
|
+
tls.accept
|
|
60
|
+
verb, path = tls.gets.to_s.split
|
|
61
|
+
origin = port == '443' ? "https://#{host}" : "https://#{host}:#{port}"
|
|
62
|
+
serve_request(tls, verb, origin + path.to_s, read_headers(tls))
|
|
63
|
+
tls.close
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def serve_request(io, verb, url, headers)
|
|
67
|
+
body = read_body(io, headers)
|
|
68
|
+
response = @table.answer(verb, url, headers:, body:, via: :transform)
|
|
69
|
+
return io.close unless response
|
|
70
|
+
return if hung_up?(io)
|
|
71
|
+
|
|
72
|
+
write_response(io, response)
|
|
73
|
+
response.record[:delivered] = true
|
|
74
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write_response(io, response)
|
|
79
|
+
body = response.body.b
|
|
80
|
+
io.write(head(response, body.bytesize))
|
|
81
|
+
io.flush
|
|
82
|
+
sleep(response.body_delay) if response.body_delay
|
|
83
|
+
io.write(body)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A transform that gave up (its own timeout, an abort signal) has closed the connection by now.
|
|
87
|
+
def hung_up?(io)
|
|
88
|
+
socket = io.respond_to?(:to_io) ? io.to_io : io
|
|
89
|
+
return false unless socket.wait_readable(0)
|
|
90
|
+
|
|
91
|
+
socket.recv_nonblock(1, Socket::MSG_PEEK, exception: false).then { it.nil? || it.empty? }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def read_headers(io)
|
|
95
|
+
headers = {}
|
|
96
|
+
while (line = io.gets) && !line.strip.empty?
|
|
97
|
+
name, value = line.split(':', 2)
|
|
98
|
+
headers[name.strip.downcase] = value.to_s.strip
|
|
99
|
+
end
|
|
100
|
+
headers
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def read_body(io, headers)
|
|
104
|
+
return io.read(headers['content-length'].to_i) if headers['content-length']
|
|
105
|
+
return nil unless headers['transfer-encoding'].to_s.include?('chunked')
|
|
106
|
+
|
|
107
|
+
chunks = +''
|
|
108
|
+
while (size = io.gets.to_s.to_i(16)).positive?
|
|
109
|
+
chunks << io.read(size)
|
|
110
|
+
io.gets
|
|
111
|
+
end
|
|
112
|
+
io.gets
|
|
113
|
+
chunks
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def head(response, length)
|
|
117
|
+
lines = response.headers.reject { |name, _| %w[content-length connection].include?(name.downcase) }
|
|
118
|
+
.map { |name, value| "#{name}: #{value}" }
|
|
119
|
+
"HTTP/1.1 #{response.status} Mocked\r\n#{lines.join("\r\n")}\r\ncontent-length: #{length}\r\n" \
|
|
120
|
+
"connection: close\r\n\r\n"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module Testing
|
|
7
|
+
# A test's canned answers for the requests a run makes, and the record of those requests. Polling asks it
|
|
8
|
+
# through #call, standing in for OutboundRequest; the transform's requests reach it through MockProxy.
|
|
9
|
+
#
|
|
10
|
+
# Keys are urls: exact, with `*` wildcards, or a Regexp; a key may start with a method ("POST https://...").
|
|
11
|
+
# A value is an answer ({ json:, body:, status:, headers:, delay:, body_delay:, advance_clock:, error: :reset }),
|
|
12
|
+
# in seconds where it is a time; a String body,
|
|
13
|
+
# a lambda that takes the request and returns an answer, or an Array of answers used in order.
|
|
14
|
+
class MockTable
|
|
15
|
+
Response = Struct.new(:status, :headers, :body, :body_delay, :record)
|
|
16
|
+
UNMOCKED_STATUS = 599
|
|
17
|
+
METHOD_PREFIX = /\A(GET|POST|PUT|PATCH|DELETE|HEAD) /
|
|
18
|
+
|
|
19
|
+
attr_reader :requests
|
|
20
|
+
|
|
21
|
+
def initialize(mocks, on_advance_clock: nil)
|
|
22
|
+
@on_advance_clock = on_advance_clock
|
|
23
|
+
@mocks = (mocks || {}).map { |key, value| { key:, answers: value.is_a?(Array) ? value.dup : [value] } }
|
|
24
|
+
@requests = []
|
|
25
|
+
@lock = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# [response, failure], as OutboundRequest#call answers.
|
|
29
|
+
def call(verb, url, headers:, body:)
|
|
30
|
+
response = answer(verb, url, headers:, body:, via: :polling)
|
|
31
|
+
response&.record&.[]=(:delivered, true)
|
|
32
|
+
response ? [response, nil] : [nil, 'the connection was reset']
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Once the run is over: a request whose answer never reached the transform was abandoned by it.
|
|
36
|
+
def finish
|
|
37
|
+
@lock.synchronize do
|
|
38
|
+
@requests.each { |request| request[:aborted] = !request.delete(:delivered) && !request[:reset] }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# nil when the mock resets the connection. The request is recorded before it is answered, so one the
|
|
43
|
+
# transform abandons during a delay is still there; MockProxy marks it aborted.
|
|
44
|
+
def answer(verb, url, headers: {}, body: nil, via: :polling)
|
|
45
|
+
request = { method: verb.upcase, url:, headers:, body:, via: }
|
|
46
|
+
answer = claim(request)
|
|
47
|
+
entry = record(request.merge(mocked: !answer.nil?, status: nil, aborted: false))
|
|
48
|
+
response = answer.nil? ? unmocked(request) : respond(answer, request)
|
|
49
|
+
response&.record = entry
|
|
50
|
+
entry[:status] = response&.status
|
|
51
|
+
entry[:reset] = true if answer && response.nil?
|
|
52
|
+
response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def claim(request)
|
|
58
|
+
@lock.synchronize do
|
|
59
|
+
mock = @mocks.find { matches?(it[:key], request) }
|
|
60
|
+
mock && (mock[:answers].size > 1 ? mock[:answers].shift : mock[:answers].first)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def respond(answer, request)
|
|
65
|
+
answer = answer.call(request) if answer.respond_to?(:call)
|
|
66
|
+
answer = { body: answer } if answer.is_a?(String)
|
|
67
|
+
sleep(answer[:delay]) if answer[:delay]
|
|
68
|
+
@on_advance_clock&.call(answer[:advance_clock]) if answer[:advance_clock]
|
|
69
|
+
answer[:error] == :reset ? nil : build(answer)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build(answer)
|
|
73
|
+
json = answer.key?(:json)
|
|
74
|
+
body = json ? JSON.generate(answer[:json]) : answer[:body].to_s
|
|
75
|
+
type = json ? 'application/json' : 'text/plain; charset=utf-8'
|
|
76
|
+
Response.new(answer[:status] || 200, { 'content-type' => type }.merge(answer[:headers] || {}), body,
|
|
77
|
+
answer[:body_delay])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unmocked(request)
|
|
81
|
+
Response.new(UNMOCKED_STATUS, { 'content-type' => 'text/plain' },
|
|
82
|
+
"trmnlp test: no mock for #{request[:method]} #{request[:url]}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def matches?(key, request)
|
|
86
|
+
return key.match?(request[:url]) if key.is_a?(Regexp)
|
|
87
|
+
|
|
88
|
+
verb = key[METHOD_PREFIX, 1]
|
|
89
|
+
return false if verb && verb != request[:method]
|
|
90
|
+
|
|
91
|
+
pattern = key.sub(METHOD_PREFIX, '')
|
|
92
|
+
target = pattern.include?('?') ? request[:url] : request[:url].split('?', 2).first
|
|
93
|
+
/\A#{Regexp.escape(pattern).gsub('\*', '.*')}\z/.match?(target)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record(entry)
|
|
97
|
+
@lock.synchronize { @requests << entry }
|
|
98
|
+
entry
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
require_relative '../errors'
|
|
6
|
+
|
|
7
|
+
module TRMNLP
|
|
8
|
+
module Testing
|
|
9
|
+
# Node sends fetch through HTTPS_PROXY only from version 24 (NODE_USE_ENV_PROXY); an older one would
|
|
10
|
+
# skip MockProxy and reach the real network, so a Node transform is refused there rather than run.
|
|
11
|
+
module NodeVersion
|
|
12
|
+
MINIMUM_MAJOR = 24
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def check!(interpreter)
|
|
17
|
+
version = checked[interpreter] ||= Open3.capture2(interpreter, '--version').first.strip
|
|
18
|
+
return if version[/\Av?(\d+)/, 1].to_i >= MINIMUM_MAJOR
|
|
19
|
+
|
|
20
|
+
raise TestingError, "Mocking a Node transform's requests needs Node #{MINIMUM_MAJOR} or newer, " \
|
|
21
|
+
"whose fetch honors HTTPS_PROXY; #{interpreter} is #{version}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def checked = @checked ||= {}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
require_relative 'device_models'
|
|
6
|
+
require_relative 'report'
|
|
7
|
+
require_relative 'run'
|
|
8
|
+
require_relative 'screen'
|
|
9
|
+
|
|
10
|
+
module TRMNLP
|
|
11
|
+
module Testing
|
|
12
|
+
# What a test calls as `trmnl`: the plugin in the current directory, rendered or transformed with the test's
|
|
13
|
+
# inputs. Every input is optional: now:, custom_fields:, variables:, state:, previous_merge_variables:,
|
|
14
|
+
# data: (skips polling), mocks:, transform: false.
|
|
15
|
+
class Plugin
|
|
16
|
+
# Records what goes wrong on the page, for Screen#problems; it runs before anything else in it.
|
|
17
|
+
PROBLEMS_SCRIPT = <<~JS.gsub(/\s+/, ' ').strip
|
|
18
|
+
(() => {
|
|
19
|
+
const problems = window.__trmnlpProblems = [];
|
|
20
|
+
window.addEventListener('error', (event) => {
|
|
21
|
+
const source = event.target && event.target !== window && (event.target.src || event.target.href);
|
|
22
|
+
problems.push(source ? `failed to load ${source}` :
|
|
23
|
+
`${event.message}${event.filename ? ` (${event.filename}:${event.lineno})` : ''}`);
|
|
24
|
+
}, true);
|
|
25
|
+
window.addEventListener('unhandledrejection', (event) =>
|
|
26
|
+
problems.push(`unhandled rejection: ${(event.reason && event.reason.message) || event.reason}`));
|
|
27
|
+
const consoleError = console.error;
|
|
28
|
+
console.error = (...parts) => { problems.push(`console.error: ${parts.join(' ')}`); consoleError(...parts); };
|
|
29
|
+
})();
|
|
30
|
+
JS
|
|
31
|
+
PAGE_CLOCK_SCRIPT = '(() => { const offset = %d; const RealDate = Date; ' \
|
|
32
|
+
'class TestDate extends RealDate { constructor(...args) { args.length ? super(...args) : ' \
|
|
33
|
+
'super(RealDate.now() + offset); } static now() { return RealDate.now() + offset; } } ' \
|
|
34
|
+
'window.Date = TestDate; })();'
|
|
35
|
+
|
|
36
|
+
def initialize(dir, browser:, authority:)
|
|
37
|
+
@dir = dir
|
|
38
|
+
@browser = browser
|
|
39
|
+
@authority = authority
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The plugin in another folder, rendered in the same browser: a built copy, say.
|
|
43
|
+
def plugin(dir) = self.class.new(File.expand_path(dir), browser: @browser, authority: @authority)
|
|
44
|
+
|
|
45
|
+
def transform(device: 'og_plus', orientation: :landscape, now: nil, **)
|
|
46
|
+
device = DeviceModels.find(device, orientation:)
|
|
47
|
+
run(now, **).transform(device: device.render_params).tap { Report.current&.record_run(it) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A render also takes head: (markup for the page's <head>, before the Framework loads), wait_for: (a
|
|
51
|
+
# JavaScript expression the page must reach before it is captured) and wait_for_timeout: (seconds).
|
|
52
|
+
# rubocop:disable-next Metrics/ParameterLists -- one keyword per choice a render offers
|
|
53
|
+
def render(view: 'full', device: 'og_plus', orientation: :landscape, palette: nil, dark_mode: false, theme: nil,
|
|
54
|
+
now: nil, head: nil, wait_for: nil, wait_for_timeout: 5, fresh_browser: false, **)
|
|
55
|
+
device = DeviceModels.find(device, orientation:, palette:)
|
|
56
|
+
classes = [device.screen_classes, ('screen--dark-mode' if dark_mode)].compact.join(' ')
|
|
57
|
+
result = run(now, **).render(view:, device: device.render_params, screen_classes: classes, theme:)
|
|
58
|
+
html = with_problems_trap(with_head(with_page_clock(result.html, now), head))
|
|
59
|
+
browser = fresh_browser ? @browser.fresh : @browser
|
|
60
|
+
Report.current&.record_run(result)
|
|
61
|
+
Screen.new(html:, device:, view:, browser:, result:, wait_for:, wait_for_timeout:)
|
|
62
|
+
.tap { Report.current&.record_screen(it) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def run(now, **) = Run.new(plugin: @dir, authority: @authority, now: time(now), **)
|
|
68
|
+
|
|
69
|
+
def time(value)
|
|
70
|
+
case value
|
|
71
|
+
when nil then nil
|
|
72
|
+
when Time then value.utc
|
|
73
|
+
when Date then value.to_time.utc
|
|
74
|
+
else Time.iso8601(value.to_s).utc
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def with_problems_trap(html) = html.sub(/<head>/i) { "<head><script>#{PROBLEMS_SCRIPT}</script>" }
|
|
79
|
+
|
|
80
|
+
def with_head(html, head) = head ? html.sub(%r{</head>}i) { "#{head}</head>" } : html
|
|
81
|
+
|
|
82
|
+
# Scripts in the markup see the test's time too; the page's clock then runs on from it.
|
|
83
|
+
def with_page_clock(html, now)
|
|
84
|
+
return html unless now
|
|
85
|
+
|
|
86
|
+
offset = ((time(now).to_f - Time.now.to_f) * 1000).round
|
|
87
|
+
html.sub(/<head>/i) { "<head><script>#{format(PAGE_CLOCK_SCRIPT, offset)}</script>" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>trmnlp test report</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --ink: #1d1d1f; --muted: #6e6e73; --line: #d2d2d7; --pass: #1a7f37; --fail: #cf222e; --pending: #9a6700; --box: #0969da; }
|
|
9
|
+
body { font: 14px/1.5 system-ui, sans-serif; color: var(--ink); margin: 0 auto; padding: 24px 16px; max-width: 1400px; }
|
|
10
|
+
header { display: flex; flex-wrap: wrap; gap: 16px; align-items: baseline; border-bottom: 1px solid var(--line); padding-bottom: 12px; }
|
|
11
|
+
h1 { font-size: 20px; margin: 0; }
|
|
12
|
+
.counts span { margin-right: 12px; }
|
|
13
|
+
.passed { color: var(--pass); } .failed { color: var(--fail); } .pending { color: var(--pending); }
|
|
14
|
+
label { color: var(--muted); cursor: pointer; }
|
|
15
|
+
label::before { content: '☐ '; }
|
|
16
|
+
#outlines:checked ~ header label[for=outlines]::before, #failures:checked ~ header label[for=failures]::before { content: '☑ '; }
|
|
17
|
+
section { border-bottom: 1px solid var(--line); padding: 16px 0; }
|
|
18
|
+
section h2 { font-size: 15px; margin: 0 0 4px; }
|
|
19
|
+
.location, .run { color: var(--muted); font-size: 12px; }
|
|
20
|
+
pre { background: #f6f8fa; padding: 8px; overflow-x: auto; white-space: pre-wrap; }
|
|
21
|
+
.screens { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 8px; }
|
|
22
|
+
figure { margin: 0; max-width: 100%; }
|
|
23
|
+
.frame { position: relative; border: 1px solid var(--line); max-width: min(100%, 640px); }
|
|
24
|
+
.frame img, .frame svg { display: block; width: 100%; height: auto; }
|
|
25
|
+
.frame svg { position: absolute; inset: 0; display: none; }
|
|
26
|
+
#outlines:checked ~ main .frame svg { display: block; }
|
|
27
|
+
#failures:checked ~ main section:not(.failed-example) { display: none; }
|
|
28
|
+
rect { fill: none; stroke: var(--box); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
|
29
|
+
figcaption { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
|
30
|
+
.problems { color: var(--fail); font-size: 12px; margin: 4px 0 0; padding-left: 16px; }
|
|
31
|
+
table { border-collapse: collapse; font-size: 12px; margin-top: 4px; }
|
|
32
|
+
td { border-top: 1px solid var(--line); padding: 2px 8px 2px 0; }
|
|
33
|
+
</style>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<input type="checkbox" id="outlines" hidden>
|
|
37
|
+
<input type="checkbox" id="failures" hidden>
|
|
38
|
+
<header>
|
|
39
|
+
<h1>trmnlp test</h1>
|
|
40
|
+
<div class="counts">
|
|
41
|
+
<span class="passed"><%= count('passed') %> passed</span>
|
|
42
|
+
<span class="failed"><%= count('failed') %> failed</span>
|
|
43
|
+
<span class="pending"><%= count('pending') %> pending</span>
|
|
44
|
+
<span><%= examples.sum { it[:screens].size } %> screens</span>
|
|
45
|
+
</div>
|
|
46
|
+
<label for="outlines">outline every drawn box</label>
|
|
47
|
+
<label for="failures">failures only</label>
|
|
48
|
+
</header>
|
|
49
|
+
<main>
|
|
50
|
+
<% examples.each do |example| %>
|
|
51
|
+
<section class="<%= 'failed-example' if example[:status] == 'failed' %>">
|
|
52
|
+
<h2 class="<%= example[:status] %>"><%= h(example[:description]) %></h2>
|
|
53
|
+
<div class="location"><%= h(example[:location]) %> · <%= example[:status] %></div>
|
|
54
|
+
<% if example[:message] %><pre><%= h(example[:message]) %></pre><% end %>
|
|
55
|
+
<% example[:runs].each do |run| %>
|
|
56
|
+
<div class="run">transform: <%= run[:duration_ms] %> ms, <%= run[:max_memory_mb] || '?' %> MB<%= ", error: #{h(run[:error])}" if run[:error] %></div>
|
|
57
|
+
<% if run[:requests].any? %>
|
|
58
|
+
<table><% run[:requests].each do |request| %>
|
|
59
|
+
<tr><td><%= request[:via] %></td><td><%= request[:method] %></td><td><%= h(request[:url]) %></td><td><%= request[:status] %><%= ' aborted' if request[:aborted] %></td></tr>
|
|
60
|
+
<% end %></table>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% end %>
|
|
63
|
+
<div class="screens">
|
|
64
|
+
<% example[:screens].each do |screen| %>
|
|
65
|
+
<figure>
|
|
66
|
+
<div class="frame">
|
|
67
|
+
<img src="<%= screen[:image] %>" alt="<%= h(screen[:label]) %>" width="<%= screen[:width] %>" height="<%= screen[:height] %>">
|
|
68
|
+
<svg viewBox="0 0 <%= screen[:width] %> <%= screen[:height] %>"><% screen[:outlines].each do |box| %><rect x="<%= box['x'] %>" y="<%= box['y'] %>" width="<%= box['w'] %>" height="<%= box['h'] %>"><title><%= h("#{box['tag']}.#{box['class']}") %></title></rect><% end %></svg>
|
|
69
|
+
</div>
|
|
70
|
+
<figcaption><%= h(screen[:label]) %></figcaption>
|
|
71
|
+
<% if screen[:problems].any? %><ul class="problems"><% screen[:problems].each do |problem| %><li><%= h(problem) %></li><% end %></ul><% end %>
|
|
72
|
+
</figure>
|
|
73
|
+
<% end %>
|
|
74
|
+
</div>
|
|
75
|
+
</section>
|
|
76
|
+
<% end %>
|
|
77
|
+
</main>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|