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
data/lib/trmnlp/screenshot.rb
CHANGED
|
@@ -7,6 +7,26 @@ require_relative 'errors'
|
|
|
7
7
|
|
|
8
8
|
module TRMNLP
|
|
9
9
|
class Screenshot
|
|
10
|
+
# TRMNL's Converter::Html waits for these flags, longer for a map, then captures what is drawn.
|
|
11
|
+
READINESS_TIMEOUT_SECONDS = 5
|
|
12
|
+
READINESS_TIMEOUT_WITH_MAPS_SECONDS = 12
|
|
13
|
+
MAPS_DOCUMENT_PATTERN = %r{TRMNLMaps\.[a-z]|maplibre-gl(?:\.js|\.css|[/@]\d)}
|
|
14
|
+
READINESS_CHECK_SCRIPT = <<~JS
|
|
15
|
+
return document.readyState === 'complete' && window.TRMNL_PLUGINS_READY === true &&
|
|
16
|
+
window.TRMNL_HIGHCHARTS_DONE === true && window.TRMNL_CHILD_DITHER_DONE !== false
|
|
17
|
+
JS
|
|
18
|
+
# Stops timers and animation callbacks from changing the page mid-capture.
|
|
19
|
+
FREEZE_TIMERS = <<~JS
|
|
20
|
+
const noop = () => 0;
|
|
21
|
+
window.setTimeout = window.setInterval = noop;
|
|
22
|
+
window.requestAnimationFrame = noop;
|
|
23
|
+
if (window.requestIdleCallback) window.requestIdleCallback = noop;
|
|
24
|
+
for (let i = 100000; i >= 0; i--) { window.clearTimeout(i); window.clearInterval(i); }
|
|
25
|
+
if (window.ResizeObserver) window.ResizeObserver.prototype.observe = noop;
|
|
26
|
+
if (window.MutationObserver) window.MutationObserver.prototype.observe = noop;
|
|
27
|
+
window.onresize = null;
|
|
28
|
+
JS
|
|
29
|
+
|
|
10
30
|
def initialize(pool:, viewport_timeout: 5)
|
|
11
31
|
@pool = pool
|
|
12
32
|
@viewport_timeout = viewport_timeout
|
|
@@ -25,11 +45,25 @@ module TRMNLP
|
|
|
25
45
|
end
|
|
26
46
|
end
|
|
27
47
|
|
|
48
|
+
# Loads html into driver at width x height and waits until TRMNL would capture it, and until wait_for
|
|
49
|
+
# (a JavaScript expression) is true when given: a page still drawing would be stopped by the frozen timers.
|
|
50
|
+
# rubocop:disable-next Metrics/ParameterLists -- the page, its size, and what to wait for
|
|
51
|
+
def show(driver, html, width, height, wait_for: nil, wait_for_timeout: READINESS_TIMEOUT_SECONDS)
|
|
52
|
+
resize(driver, width, height)
|
|
53
|
+
load_page(driver, html) { wait_for_expression(driver, wait_for, wait_for_timeout) if wait_for }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def capture(driver)
|
|
57
|
+
file = Tempfile.new(['screenshot', '.png'])
|
|
58
|
+
driver.save_screenshot(file.path)
|
|
59
|
+
file.close
|
|
60
|
+
file
|
|
61
|
+
end
|
|
62
|
+
|
|
28
63
|
private
|
|
29
64
|
|
|
30
65
|
def render(driver, html, width, height)
|
|
31
|
-
|
|
32
|
-
load_page(driver, html)
|
|
66
|
+
show(driver, html, width, height)
|
|
33
67
|
capture(driver)
|
|
34
68
|
end
|
|
35
69
|
|
|
@@ -87,23 +121,28 @@ module TRMNLP
|
|
|
87
121
|
document.close();
|
|
88
122
|
JS
|
|
89
123
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
end
|
|
93
|
-
|
|
124
|
+
wait_until_ready(driver, html)
|
|
125
|
+
yield if block_given?
|
|
94
126
|
driver.execute_script('return document.fonts && document.fonts.ready')
|
|
95
127
|
|
|
96
128
|
driver.execute_script(<<~JS)
|
|
97
129
|
document.documentElement.style.overflow = 'hidden';
|
|
98
130
|
document.body.style.overflow = 'hidden';
|
|
99
131
|
JS
|
|
132
|
+
driver.execute_script(FREEZE_TIMERS)
|
|
100
133
|
end
|
|
101
134
|
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
135
|
+
def wait_for_expression(driver, expression, timeout)
|
|
136
|
+
Selenium::WebDriver::Wait.new(timeout:, interval: 0.05).until { driver.execute_script("return !!(#{expression});") }
|
|
137
|
+
rescue Selenium::WebDriver::Error::TimeoutError
|
|
138
|
+
raise RenderError, "The page did not reach #{expression} within #{timeout}s"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def wait_until_ready(driver, html)
|
|
142
|
+
timeout = html.match?(MAPS_DOCUMENT_PATTERN) ? READINESS_TIMEOUT_WITH_MAPS_SECONDS : READINESS_TIMEOUT_SECONDS
|
|
143
|
+
Selenium::WebDriver::Wait.new(timeout:, interval: 0.05).until { driver.execute_script(READINESS_CHECK_SCRIPT) }
|
|
144
|
+
rescue Selenium::WebDriver::Error::TimeoutError
|
|
145
|
+
nil
|
|
107
146
|
end
|
|
108
147
|
end
|
|
109
148
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
# A select field's options as TRMNL saves them: "New York" as new_york, { Label: value } as its value.
|
|
5
|
+
class SelectFieldChoices
|
|
6
|
+
def initialize(field)
|
|
7
|
+
options = field['options']
|
|
8
|
+
@options = options.is_a?(Array) ? options.compact : []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pairs
|
|
12
|
+
@pairs ||= @options.map do |option|
|
|
13
|
+
option.is_a?(Hash) ? [option.keys.first, option.values.first] : [option, option.to_s.downcase.tr(' ', '_')]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A label becomes its value; a value, or anything the options do not list, is kept as it is.
|
|
18
|
+
def stored_value(input)
|
|
19
|
+
return input.map { stored_value(it) } if input.is_a?(Array)
|
|
20
|
+
return input if input.to_s.empty? || pairs.any? { |_, value| value.to_s == input.to_s }
|
|
21
|
+
|
|
22
|
+
pairs.find { |label, _| label.to_s == input.to_s }&.last&.to_s || input
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
require_relative '../browser_pool'
|
|
7
|
+
require_relative '../firefox_driver'
|
|
8
|
+
require_relative '../screen_generator'
|
|
9
|
+
require_relative '../screenshot'
|
|
10
|
+
require_relative 'inline_stylesheets'
|
|
11
|
+
|
|
12
|
+
module TRMNLP
|
|
13
|
+
module Testing
|
|
14
|
+
# The Firefox one example renders in, checked out of the pool on first use and back in by #release.
|
|
15
|
+
# Pages load the way `trmnlp build --png` loads them, through ScreenGenerator and Screenshot.
|
|
16
|
+
class Browser
|
|
17
|
+
def initialize(pool:)
|
|
18
|
+
@pool = pool
|
|
19
|
+
@screenshot = Screenshot.new(pool:)
|
|
20
|
+
@pages = {}.compare_by_identity
|
|
21
|
+
@fresh_browsers = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A Firefox of its own, with nothing cached, closed when this browser is released.
|
|
25
|
+
def fresh
|
|
26
|
+
pool = BrowserPool.new(driver_factory: FirefoxDriver.method(:build), max_size: 1)
|
|
27
|
+
self.class.new(pool:).tap { @fresh_browsers << [it, pool] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The PNG path, quantized to the device's bit depth like a build's.
|
|
31
|
+
def capture(screen)
|
|
32
|
+
@capturing = screen
|
|
33
|
+
keep(generate(screen))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ScreenGenerator's screenshot interface: html arrives with chart libraries swapped for TRMNL's copies.
|
|
37
|
+
def call(html:, width:, height:)
|
|
38
|
+
@pages[@capturing] = [html, width, height]
|
|
39
|
+
show(@capturing)
|
|
40
|
+
@screenshot.capture(driver)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Yields the driver with screen's page loaded, reloading it if a later render replaced it.
|
|
44
|
+
def on(screen)
|
|
45
|
+
show(screen) unless @showing.equal?(screen)
|
|
46
|
+
yield driver
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def release
|
|
50
|
+
@fresh_browsers.each do |browser, pool|
|
|
51
|
+
browser.release
|
|
52
|
+
pool.shutdown
|
|
53
|
+
end
|
|
54
|
+
@fresh_browsers.clear
|
|
55
|
+
@pool.checkin(@driver) if @driver
|
|
56
|
+
@driver = nil
|
|
57
|
+
@showing = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def generate(screen)
|
|
63
|
+
device = screen.device
|
|
64
|
+
html = InlineStylesheets.call(screen.html)
|
|
65
|
+
ScreenGenerator.new(html, screenshot: self, width: device.width, height: device.height,
|
|
66
|
+
color_depth: device.bit_depth).process
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def driver = @driver ||= @pool.checkout
|
|
70
|
+
|
|
71
|
+
def show(screen)
|
|
72
|
+
html, width, height = @pages.fetch(screen)
|
|
73
|
+
@screenshot.show(driver, html, width, height,
|
|
74
|
+
wait_for: screen.wait_for, wait_for_timeout: screen.wait_for_timeout)
|
|
75
|
+
@showing = screen
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def keep(image)
|
|
79
|
+
path = File.join(Dir.mktmpdir('trmnlp-screen-'), 'screen.png')
|
|
80
|
+
FileUtils.cp(image.path, path)
|
|
81
|
+
path
|
|
82
|
+
ensure
|
|
83
|
+
image.close!
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module TRMNLP
|
|
8
|
+
module Testing
|
|
9
|
+
# A throwaway authority the transform process is told to trust, so MockProxy can answer HTTPS.
|
|
10
|
+
# Lives for one server process; nothing is written outside its temp directory.
|
|
11
|
+
class CertificateAuthority
|
|
12
|
+
# Wide enough for any time a test freezes the clock at.
|
|
13
|
+
VALID_FROM = Time.utc(2000, 1, 1)
|
|
14
|
+
VALID_UNTIL = Time.utc(2099, 12, 31)
|
|
15
|
+
|
|
16
|
+
attr_reader :pem_path, :bundle_path, :php_ini_dir
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@key = OpenSSL::PKey::RSA.new(2048)
|
|
20
|
+
@certificate = sign(subject: 'trmnlp test CA', public_key: @key.public_key, authority: true)
|
|
21
|
+
@leaves = {}
|
|
22
|
+
@lock = Mutex.new
|
|
23
|
+
write_files(Dir.mktmpdir('trmnlp-ca-'))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The variables that make Node, Python, Ruby and PHP trust this authority.
|
|
27
|
+
def environment
|
|
28
|
+
{ 'NODE_EXTRA_CA_CERTS' => pem_path, 'SSL_CERT_FILE' => bundle_path, 'REQUESTS_CA_BUNDLE' => bundle_path,
|
|
29
|
+
'CURL_CA_BUNDLE' => bundle_path, 'PHP_INI_SCAN_DIR' => ":#{php_ini_dir}" }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def remove = FileUtils.remove_entry(File.dirname(pem_path))
|
|
33
|
+
|
|
34
|
+
def ssl_context_for(host)
|
|
35
|
+
@lock.synchronize do
|
|
36
|
+
@leaves[host] ||= OpenSSL::SSL::SSLContext.new.tap do |context|
|
|
37
|
+
context.key = @key
|
|
38
|
+
context.cert = sign(subject: host, public_key: @key.public_key, authority: false, host:)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def sign(subject:, public_key:, authority:, host: nil)
|
|
46
|
+
certificate = OpenSSL::X509::Certificate.new
|
|
47
|
+
certificate.version = 2
|
|
48
|
+
certificate.serial = OpenSSL::BN.rand(64)
|
|
49
|
+
certificate.subject = OpenSSL::X509::Name.parse("/CN=#{subject}")
|
|
50
|
+
certificate.issuer = authority ? certificate.subject : @certificate.subject
|
|
51
|
+
certificate.public_key = public_key
|
|
52
|
+
certificate.not_before = VALID_FROM
|
|
53
|
+
certificate.not_after = VALID_UNTIL
|
|
54
|
+
add_extensions(certificate, authority, host)
|
|
55
|
+
certificate.sign(@key, OpenSSL::Digest.new('SHA256'))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def add_extensions(certificate, authority, host)
|
|
59
|
+
factory = OpenSSL::X509::ExtensionFactory.new(authority ? certificate : @certificate, certificate)
|
|
60
|
+
if authority
|
|
61
|
+
certificate.add_extension(factory.create_extension('basicConstraints', 'CA:TRUE', true))
|
|
62
|
+
certificate.add_extension(factory.create_extension('keyUsage', 'keyCertSign,cRLSign', true))
|
|
63
|
+
else
|
|
64
|
+
name = host.match?(/\A[\d.]+\z/) ? "IP:#{host}" : "DNS:#{host}"
|
|
65
|
+
certificate.add_extension(factory.create_extension('subjectAltName', name))
|
|
66
|
+
certificate.add_extension(factory.create_extension('extendedKeyUsage', 'serverAuth'))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write_files(dir)
|
|
71
|
+
@pem_path = File.join(dir, 'ca.pem')
|
|
72
|
+
@bundle_path = File.join(dir, 'bundle.pem')
|
|
73
|
+
@php_ini_dir = File.join(dir, 'php')
|
|
74
|
+
File.write(pem_path, @certificate.to_pem)
|
|
75
|
+
system_file = OpenSSL::X509::DEFAULT_CERT_FILE
|
|
76
|
+
system_bundle = File.exist?(system_file) ? File.read(system_file) : ''
|
|
77
|
+
File.write(bundle_path, system_bundle + @certificate.to_pem)
|
|
78
|
+
Dir.mkdir(php_ini_dir)
|
|
79
|
+
php_settings = "curl.cainfo=#{bundle_path}\nopenssl.cafile=#{bundle_path}\n"
|
|
80
|
+
File.write(File.join(php_ini_dir, 'trmnlp-ca.ini'), php_settings)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -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
|