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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +48 -0
- data/db/data/and_x_more.yml +86 -0
- data/db/data/locales/cs.yml +339 -0
- data/db/data/locales/da.yml +339 -0
- data/db/data/locales/de-AT.yml +341 -0
- data/db/data/locales/de.yml +341 -0
- data/db/data/locales/en-AU.yml +79 -0
- data/db/data/locales/en-GB.yml +79 -0
- data/db/data/locales/en.yml +341 -0
- data/db/data/locales/es-ES.yml +341 -0
- data/db/data/locales/fr.yml +339 -0
- data/db/data/locales/he.yml +339 -0
- data/db/data/locales/hu.yml +339 -0
- data/db/data/locales/id.yml +339 -0
- data/db/data/locales/is.yml +339 -0
- data/db/data/locales/it.yml +341 -0
- data/db/data/locales/ja.yml +342 -0
- data/db/data/locales/ko.yml +337 -0
- data/db/data/locales/lt.yml +340 -0
- data/db/data/locales/nl.yml +339 -0
- data/db/data/locales/no.yml +4 -0
- data/db/data/locales/pl.yml +342 -0
- data/db/data/locales/pt-BR.yml +340 -0
- data/db/data/locales/raw.yml +256 -0
- data/db/data/locales/ro.yml +339 -0
- data/db/data/locales/ru.yml +342 -0
- data/db/data/locales/sk.yml +339 -0
- data/db/data/locales/sv.yml +340 -0
- data/db/data/locales/uk.yml +339 -0
- data/db/data/locales/zh-CN.yml +338 -0
- data/db/data/locales/zh-HK.yml +338 -0
- data/lib/trmnlp/app.rb +5 -4
- data/lib/trmnlp/async_callback.rb +75 -0
- data/lib/trmnlp/browser_pool.rb +8 -8
- data/lib/trmnlp/cli.rb +7 -0
- data/lib/trmnlp/commands/serve.rb +3 -0
- data/lib/trmnlp/commands/test.rb +25 -0
- data/lib/trmnlp/config/plugin.rb +52 -12
- data/lib/trmnlp/config/project.rb +23 -10
- data/lib/trmnlp/config.rb +2 -2
- data/lib/trmnlp/context.rb +29 -6
- data/lib/trmnlp/errors.rb +1 -0
- data/lib/trmnlp/firefox_driver.rb +0 -3
- data/lib/trmnlp/framework_version.rb +9 -0
- data/lib/trmnlp/image_quantizer.rb +28 -15
- data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
- data/lib/trmnlp/lint/diagnostic.rb +5 -0
- data/lib/trmnlp/lint/source.rb +6 -0
- data/lib/trmnlp/lint.rb +3 -1
- data/lib/trmnlp/merged_plugin_locals.rb +57 -0
- data/lib/trmnlp/outbound_request.rb +99 -0
- data/lib/trmnlp/paths.rb +15 -8
- data/lib/trmnlp/poller.rb +100 -47
- data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
- data/lib/trmnlp/rails_helpers.rb +41 -0
- data/lib/trmnlp/renderer.rb +40 -11
- data/lib/trmnlp/screen_generator.rb +14 -2
- data/lib/trmnlp/screenshot.rb +50 -11
- data/lib/trmnlp/select_field_choices.rb +25 -0
- 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 +51 -9
- data/lib/trmnlp/transform_state.rb +51 -0
- data/lib/trmnlp/user_data_assembler.rb +63 -25
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp/watcher.rb +2 -1
- data/lib/trmnlp/webhook_payload.rb +74 -0
- data/lib/trmnlp/webhook_receiver.rb +78 -0
- data/lib/trmnlp.rb +2 -1
- 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 +6 -0
- data/web/public/index.js +18 -6
- data/web/views/index.erb +7 -0
- data/web/views/render_html.erb +37 -1
- 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
|