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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +37 -0
  4. data/lib/trmnlp/browser_pool.rb +8 -8
  5. data/lib/trmnlp/cli.rb +7 -0
  6. data/lib/trmnlp/commands/test.rb +25 -0
  7. data/lib/trmnlp/config/project.rb +9 -6
  8. data/lib/trmnlp/config.rb +2 -2
  9. data/lib/trmnlp/context.rb +12 -4
  10. data/lib/trmnlp/errors.rb +1 -0
  11. data/lib/trmnlp/paths.rb +4 -2
  12. data/lib/trmnlp/poller.rb +20 -6
  13. data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
  14. data/lib/trmnlp/renderer.rb +4 -1
  15. data/lib/trmnlp/screenshot.rb +23 -9
  16. data/lib/trmnlp/testing/browser.rb +87 -0
  17. data/lib/trmnlp/testing/certificate_authority.rb +84 -0
  18. data/lib/trmnlp/testing/device_models.rb +80 -0
  19. data/lib/trmnlp/testing/frozen_clock.rb +86 -0
  20. data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
  21. data/lib/trmnlp/testing/memory_sampler.rb +44 -0
  22. data/lib/trmnlp/testing/mock_proxy.rb +124 -0
  23. data/lib/trmnlp/testing/mock_table.rb +102 -0
  24. data/lib/trmnlp/testing/node_version.rb +27 -0
  25. data/lib/trmnlp/testing/plugin.rb +91 -0
  26. data/lib/trmnlp/testing/report.html.erb +79 -0
  27. data/lib/trmnlp/testing/report.rb +91 -0
  28. data/lib/trmnlp/testing/rspec.rb +89 -0
  29. data/lib/trmnlp/testing/run.rb +150 -0
  30. data/lib/trmnlp/testing/screen.rb +83 -0
  31. data/lib/trmnlp/testing/snapshot.rb +55 -0
  32. data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
  33. data/lib/trmnlp/transform_pipeline.rb +5 -4
  34. data/lib/trmnlp/transform_state.rb +4 -1
  35. data/lib/trmnlp/version.rb +1 -1
  36. data/lib/trmnlp.rb +1 -0
  37. data/templates/init/.github/workflows/trmnl.yml +33 -2
  38. data/templates/init/.gitignore +3 -0
  39. data/templates/init/tests/plugin_spec.rb +32 -0
  40. data/trmnl_preview.gemspec +5 -0
  41. data/web/views/render_html.erb +1 -1
  42. 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>