trmnl_preview 0.15.0 → 0.17.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +48 -0
  4. data/db/data/and_x_more.yml +86 -0
  5. data/db/data/locales/cs.yml +339 -0
  6. data/db/data/locales/da.yml +339 -0
  7. data/db/data/locales/de-AT.yml +341 -0
  8. data/db/data/locales/de.yml +341 -0
  9. data/db/data/locales/en-AU.yml +79 -0
  10. data/db/data/locales/en-GB.yml +79 -0
  11. data/db/data/locales/en.yml +341 -0
  12. data/db/data/locales/es-ES.yml +341 -0
  13. data/db/data/locales/fr.yml +339 -0
  14. data/db/data/locales/he.yml +339 -0
  15. data/db/data/locales/hu.yml +339 -0
  16. data/db/data/locales/id.yml +339 -0
  17. data/db/data/locales/is.yml +339 -0
  18. data/db/data/locales/it.yml +341 -0
  19. data/db/data/locales/ja.yml +342 -0
  20. data/db/data/locales/ko.yml +337 -0
  21. data/db/data/locales/lt.yml +340 -0
  22. data/db/data/locales/nl.yml +339 -0
  23. data/db/data/locales/no.yml +4 -0
  24. data/db/data/locales/pl.yml +342 -0
  25. data/db/data/locales/pt-BR.yml +340 -0
  26. data/db/data/locales/raw.yml +256 -0
  27. data/db/data/locales/ro.yml +339 -0
  28. data/db/data/locales/ru.yml +342 -0
  29. data/db/data/locales/sk.yml +339 -0
  30. data/db/data/locales/sv.yml +340 -0
  31. data/db/data/locales/uk.yml +339 -0
  32. data/db/data/locales/zh-CN.yml +338 -0
  33. data/db/data/locales/zh-HK.yml +338 -0
  34. data/lib/trmnlp/app.rb +5 -4
  35. data/lib/trmnlp/async_callback.rb +75 -0
  36. data/lib/trmnlp/browser_pool.rb +8 -8
  37. data/lib/trmnlp/cli.rb +7 -0
  38. data/lib/trmnlp/commands/serve.rb +3 -0
  39. data/lib/trmnlp/commands/test.rb +25 -0
  40. data/lib/trmnlp/config/plugin.rb +52 -12
  41. data/lib/trmnlp/config/project.rb +23 -10
  42. data/lib/trmnlp/config.rb +2 -2
  43. data/lib/trmnlp/context.rb +29 -6
  44. data/lib/trmnlp/errors.rb +1 -0
  45. data/lib/trmnlp/firefox_driver.rb +0 -3
  46. data/lib/trmnlp/framework_version.rb +9 -0
  47. data/lib/trmnlp/image_quantizer.rb +28 -15
  48. data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
  49. data/lib/trmnlp/lint/diagnostic.rb +5 -0
  50. data/lib/trmnlp/lint/source.rb +6 -0
  51. data/lib/trmnlp/lint.rb +3 -1
  52. data/lib/trmnlp/merged_plugin_locals.rb +57 -0
  53. data/lib/trmnlp/outbound_request.rb +99 -0
  54. data/lib/trmnlp/paths.rb +15 -8
  55. data/lib/trmnlp/poller.rb +100 -47
  56. data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
  57. data/lib/trmnlp/rails_helpers.rb +41 -0
  58. data/lib/trmnlp/renderer.rb +40 -11
  59. data/lib/trmnlp/screen_generator.rb +14 -2
  60. data/lib/trmnlp/screenshot.rb +50 -11
  61. data/lib/trmnlp/select_field_choices.rb +25 -0
  62. data/lib/trmnlp/testing/browser.rb +87 -0
  63. data/lib/trmnlp/testing/certificate_authority.rb +84 -0
  64. data/lib/trmnlp/testing/device_models.rb +80 -0
  65. data/lib/trmnlp/testing/frozen_clock.rb +86 -0
  66. data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
  67. data/lib/trmnlp/testing/memory_sampler.rb +44 -0
  68. data/lib/trmnlp/testing/mock_proxy.rb +124 -0
  69. data/lib/trmnlp/testing/mock_table.rb +102 -0
  70. data/lib/trmnlp/testing/node_version.rb +27 -0
  71. data/lib/trmnlp/testing/plugin.rb +91 -0
  72. data/lib/trmnlp/testing/report.html.erb +79 -0
  73. data/lib/trmnlp/testing/report.rb +91 -0
  74. data/lib/trmnlp/testing/rspec.rb +89 -0
  75. data/lib/trmnlp/testing/run.rb +150 -0
  76. data/lib/trmnlp/testing/screen.rb +83 -0
  77. data/lib/trmnlp/testing/snapshot.rb +55 -0
  78. data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
  79. data/lib/trmnlp/transform_pipeline.rb +51 -9
  80. data/lib/trmnlp/transform_state.rb +51 -0
  81. data/lib/trmnlp/user_data_assembler.rb +63 -25
  82. data/lib/trmnlp/version.rb +1 -1
  83. data/lib/trmnlp/watcher.rb +2 -1
  84. data/lib/trmnlp/webhook_payload.rb +74 -0
  85. data/lib/trmnlp/webhook_receiver.rb +78 -0
  86. data/lib/trmnlp.rb +2 -1
  87. data/templates/init/.github/workflows/trmnl.yml +33 -2
  88. data/templates/init/.gitignore +3 -0
  89. data/templates/init/tests/plugin_spec.rb +32 -0
  90. data/trmnl_preview.gemspec +6 -0
  91. data/web/public/index.js +18 -6
  92. data/web/views/index.erb +7 -0
  93. data/web/views/render_html.erb +37 -1
  94. metadata +115 -1
@@ -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>
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'rspec/core'
7
+
8
+ module TRMNLP
9
+ module Testing
10
+ # What `trmnlp test --report DIR` writes: every example with each screen it rendered (the PNG, the boxes
11
+ # of everything drawn, the page's problems) and each transform it ran, as DIR/index.html and
12
+ # DIR/report.json. Under GitHub Actions the counts and failures go to the step summary too.
13
+ class Report
14
+ TEMPLATE = File.expand_path('report.html.erb', __dir__)
15
+ OUTLINES_SCRIPT = <<~JS
16
+ (() => [...document.querySelectorAll('.view *')].slice(0, 3000).map((el) => {
17
+ const box = el.getBoundingClientRect();
18
+ return { tag: el.tagName.toLowerCase(), class: String(el.className.baseVal ?? el.className),
19
+ x: box.x, y: box.y, w: box.width, h: box.height };
20
+ }).filter((box) => box.w > 0 && box.h > 0))()
21
+ JS
22
+
23
+ class << self
24
+ attr_accessor :current
25
+ end
26
+
27
+ def initialize(dir)
28
+ @dir = dir
29
+ @examples = {}
30
+ @images = 0
31
+ end
32
+
33
+ def record_screen(screen)
34
+ entry&.fetch(:screens)&.push(
35
+ label: "#{screen.device.name} · #{screen.view}", width: screen.device.width, height: screen.device.height,
36
+ image: copy_image(screen.png_path), outlines: screen.evaluate(OUTLINES_SCRIPT), problems: screen.problems
37
+ )
38
+ end
39
+
40
+ def record_run(run)
41
+ return unless run.duration_ms
42
+
43
+ entry&.fetch(:runs)&.push(duration_ms: run.duration_ms, max_memory_mb: run.max_memory_mb, error: run.error,
44
+ requests: run.requests.map { it.slice(:method, :url, :status, :via, :aborted) })
45
+ end
46
+
47
+ def example_passed(notification) = finish(notification.example, 'passed')
48
+ def example_pending(notification) = finish(notification.example, 'pending')
49
+ def example_failed(notification) = finish(notification.example, 'failed', notification.example.exception&.message)
50
+
51
+ def close(_notification)
52
+ FileUtils.mkdir_p(@dir)
53
+ File.write(File.join(@dir, 'report.json'), JSON.pretty_generate(examples: examples))
54
+ File.write(File.join(@dir, 'index.html'), ERB.new(File.read(TEMPLATE)).result(binding))
55
+ write_step_summary if ENV['GITHUB_STEP_SUMMARY']
56
+ end
57
+
58
+ private
59
+
60
+ def examples = @examples.values
61
+
62
+ def entry(example = RSpec.current_example)
63
+ return unless example
64
+
65
+ @examples[example.id] ||= { description: example.full_description, location: example.location,
66
+ status: 'running', message: nil, screens: [], runs: [] }
67
+ end
68
+
69
+ def finish(example, status, message = nil) = entry(example).merge!(status:, message:)
70
+
71
+ def copy_image(path)
72
+ name = "images/#{@images += 1}.png"
73
+ FileUtils.mkdir_p(File.join(@dir, 'images'))
74
+ FileUtils.cp(path, File.join(@dir, name))
75
+ name
76
+ end
77
+
78
+ def count(status) = examples.count { it[:status] == status }
79
+
80
+ def write_step_summary
81
+ counts = "#{count('passed')} passed, #{count('failed')} failed, #{count('pending')} pending, " \
82
+ "#{examples.sum { it[:screens].size }} screens"
83
+ lines = ["### trmnlp test\n", "#{counts}\n"]
84
+ examples.select { it[:status] == 'failed' }.each { lines << "- ❌ #{it[:description]} (`#{it[:location]}`)" }
85
+ File.write(ENV.fetch('GITHUB_STEP_SUMMARY'), "#{lines.join("\n")}\n", mode: 'a')
86
+ end
87
+
88
+ def h(text) = ERB::Util.html_escape(text.to_s)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by `trmnlp test`: gives each example `trmnl`, Capybara's matchers and trmnlp's own.
4
+ require 'capybara/rspec/matchers'
5
+ require 'rspec/core'
6
+ require 'rspec/expectations'
7
+
8
+ require_relative '../../trmnlp'
9
+ require_relative '../browser_pool'
10
+ require_relative '../firefox_driver'
11
+ require_relative 'browser'
12
+ require_relative 'certificate_authority'
13
+ require_relative 'plugin'
14
+ require_relative 'report'
15
+ require_relative 'snapshot'
16
+
17
+ module TRMNLP
18
+ module Testing
19
+ PLUGIN_DIR_ENV_KEY = 'TRMNLP_PLUGIN_DIR'
20
+
21
+ def self.plugin_dir = ENV.fetch(PLUGIN_DIR_ENV_KEY, Dir.pwd)
22
+
23
+ def self.authority
24
+ @authority ||= CertificateAuthority.new.tap { |authority| at_exit { authority.remove } }
25
+ end
26
+
27
+ def self.browser_pool
28
+ @browser_pool ||= BrowserPool.new(driver_factory: FirefoxDriver.method(:build), max_size: 1)
29
+ end
30
+
31
+ module Helpers
32
+ def trmnl = @trmnl ||= Plugin.new(Testing.plugin_dir, browser: trmnl_browser, authority: Testing.authority)
33
+
34
+ def trmnl_browser = @trmnl_browser ||= Browser.new(pool: Testing.browser_pool)
35
+ end
36
+ end
37
+ end
38
+
39
+ RSpec::Matchers.define :have_no_overflow do
40
+ match { |screen| screen.overflowing.empty? }
41
+ failure_message do |screen|
42
+ "expected nothing to overflow on #{screen.inspect}, but: #{screen.overflowing.join(', ')}"
43
+ end
44
+ end
45
+
46
+ # TRMNL gives a transform 5 seconds and 128 MB (help.trmnl.com, Serverless).
47
+ RSpec::Matchers.define :stay_within_serverless_limits do
48
+ match { |run| run.duration_ms.to_i <= 5000 && run.max_memory_mb.to_f <= 128 }
49
+ failure_message do |run|
50
+ "expected the transform to stay within 5000 ms and 128 MB, but it took #{run.duration_ms} ms " \
51
+ "and #{run.max_memory_mb} MB"
52
+ end
53
+ end
54
+
55
+ RSpec::Matchers.define :have_no_problems do
56
+ match { |screen| screen.problems.empty? }
57
+ failure_message { |screen| "expected no problems on #{screen.inspect}, but: #{screen.problems.join('; ')}" }
58
+ end
59
+
60
+ RSpec::Matchers.define :fit_image_size_limit do
61
+ match { |screen| screen.png_bytes.bytesize <= screen.device.image_size_limit.to_i }
62
+ failure_message do |screen|
63
+ "expected the #{screen.device.name} PNG to fit #{screen.device.image_size_limit} bytes, " \
64
+ "but it is #{screen.png_bytes.bytesize}"
65
+ end
66
+ end
67
+
68
+ RSpec::Matchers.define :match_snapshot do |name = nil|
69
+ match do |screen|
70
+ example = RSpec.current_example
71
+ name ||= [example.full_description, screen.device.name, screen.view].join(' ').downcase.gsub(/[^a-z0-9]+/, '-')
72
+ dir = File.join(TRMNLP::Testing.plugin_dir, 'tests', 'snapshots')
73
+ @mismatch = TRMNLP::Testing::Snapshot.new(screen, name:, dir:).mismatch
74
+ @mismatch.nil?
75
+ end
76
+ failure_message { @mismatch }
77
+ end
78
+
79
+ RSpec.configure do |config|
80
+ if (report_dir = ENV.fetch('TRMNLP_REPORT_DIR', nil))
81
+ TRMNLP::Testing::Report.current = TRMNLP::Testing::Report.new(report_dir)
82
+ config.reporter.register_listener(TRMNLP::Testing::Report.current, :example_passed, :example_failed,
83
+ :example_pending, :close)
84
+ end
85
+
86
+ config.include Capybara::RSpecMatchers
87
+ config.include TRMNLP::Testing::Helpers
88
+ config.after { @trmnl_browser&.release }
89
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ require_relative '../context'
8
+ require_relative '../reporter'
9
+ require_relative '../transform_backend/subprocess'
10
+ require_relative '../transform_backend/which'
11
+ require_relative '../transform_client'
12
+ require_relative '../transform_state'
13
+ require_relative 'frozen_clock'
14
+ require_relative 'memory_sampler'
15
+ require_relative 'node_version'
16
+ require_relative 'mock_proxy'
17
+ require_relative 'mock_table'
18
+
19
+ module TRMNLP
20
+ module Testing
21
+ # One transform or render of a plugin, through trmnlp's own pipeline, in a cache directory of its own:
22
+ # the test's custom fields, saved state and previous output go in, its mocks answer every request,
23
+ # and every clock starts at its `now`.
24
+ class Run
25
+ Result = Data.define(:data, :html, :state, :requests, :log, :error, :duration_ms, :max_memory_mb)
26
+
27
+ # The transform client, remembering how long its last run took.
28
+ class TimedClient < SimpleDelegator
29
+ attr_reader :duration_ms
30
+
31
+ def execute(**) = super.tap { @duration_ms = it.duration_ms }
32
+ end
33
+
34
+ # rubocop:disable-next Metrics/ParameterLists -- one keyword per input a test can set
35
+ def initialize(plugin:, authority:, now: nil, custom_fields: {}, variables: {}, state: nil,
36
+ previous_merge_variables: nil, data: nil, mocks: {}, transform: true)
37
+ @plugin = plugin.to_s
38
+ @authority = authority
39
+ @now = now
40
+ @inputs = { custom_fields:, variables:, state:, previous_merge_variables:, data:, transform: }
41
+ @table = MockTable.new(mocks, on_advance_clock: method(:advance_clock))
42
+ @clock_offset = 0
43
+ @reporter = Reporter.new(quiet: true)
44
+ end
45
+
46
+ def transform(device: {})
47
+ call { |context| { data: context.user_data_assembler.call(device:) } }
48
+ end
49
+
50
+ def render(view:, device: {}, screen_classes: nil, theme: nil)
51
+ call do |context|
52
+ html = context.renderer.render_full_page(view, device.merge(screen_classes:, theme:))
53
+ { html:, data: context.renderer.last_data }
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :plugin, :authority, :now, :inputs, :table, :reporter
60
+
61
+ def call
62
+ Dir.mktmpdir('trmnlp-run-') do |cache_dir|
63
+ @clock_file = File.join(cache_dir, 'clock')
64
+ MockProxy.open(table:, authority:) do |proxy|
65
+ context = build_context(cache_dir, proxy)
66
+ parts = FrozenClock.around(now) do
67
+ load_source_data(context)
68
+ yield context
69
+ end
70
+ Result.new(data: nil, html: nil, **parts, **outcome(context))
71
+ end
72
+ end
73
+ end
74
+
75
+ def build_context(cache_dir, proxy)
76
+ transform_client = inputs[:transform] ? transform_client(proxy) : nil
77
+ context = Context.new(plugin, reporter:, cache_dir:, project_overrides:, outbound_request: table,
78
+ transform_client:)
79
+ seed(context.paths)
80
+ context
81
+ end
82
+
83
+ def project_overrides
84
+ overrides = { 'custom_fields' => stringify(inputs[:custom_fields]),
85
+ 'variables' => stringify(inputs[:variables]) }
86
+ inputs[:transform] ? overrides : overrides.merge('transform_runtime' => 'disabled')
87
+ end
88
+
89
+ def transform_client(proxy)
90
+ environment = proxy.environment
91
+ language = transform_language
92
+ NodeVersion.check!(interpreter(language)) if language.to_s == 'node'
93
+ if now && language
94
+ environment = environment.merge(FrozenClock.environment(now, interpreter: interpreter(language),
95
+ clock_file: @clock_file))
96
+ end
97
+ on_spawn = ->(pid) { @memory = MemorySampler.start(pid) }
98
+ backend = TransformBackend::Subprocess.new(environment:, on_spawn:)
99
+ @client = TimedClient.new(TransformClient.new(backend:))
100
+ end
101
+
102
+ def transform_language
103
+ paths = Paths.new(plugin)
104
+ _path, inferred = paths.transform_file
105
+ Config.new(paths).plugin.serverless_language || inferred
106
+ end
107
+
108
+ def interpreter(language)
109
+ commands = TransformBackend::Subprocess::INTERPRETERS.fetch(language.to_s, { cmds: [language.to_s] })[:cmds]
110
+ TransformBackend::Which.locate(TransformBackend::Which.resolve(commands)).to_s
111
+ end
112
+
113
+ def seed(paths)
114
+ write(paths.transform_state, inputs[:state]) if inputs[:state]
115
+ write(paths.transform_output, inputs[:previous_merge_variables]) if inputs[:previous_merge_variables]
116
+ end
117
+
118
+ def load_source_data(context)
119
+ if inputs[:data]
120
+ write(context.paths.user_data, inputs[:data])
121
+ elsif context.config.plugin.polling?
122
+ context.poller.poll_data
123
+ end
124
+ time = now || Time.now
125
+ File.utime(time, time, context.paths.user_data) if context.paths.user_data.exist?
126
+ end
127
+
128
+ # A mock's advance_clock: moves the transform's clock on from the test's now.
129
+ def advance_clock(seconds)
130
+ raise TestingError, 'advance_clock needs now: on the run' unless now
131
+
132
+ @clock_offset += seconds
133
+ FrozenClock.write(@clock_file, now + @clock_offset)
134
+ end
135
+
136
+ def outcome(context)
137
+ table.finish
138
+ { state: TransformState.new(paths: context.paths).read, requests: table.requests, log: reporter.messages,
139
+ error: context.transform_pipeline.error, duration_ms: @client&.duration_ms, max_memory_mb: @memory&.peak_mb }
140
+ end
141
+
142
+ def stringify(value) = JSON.parse(JSON.generate(value || {}))
143
+
144
+ def write(path, value)
145
+ path.dirname.mkpath
146
+ path.write(JSON.generate(value))
147
+ end
148
+ end
149
+ end
150
+ end