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
@@ -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
- resize(driver, width, height)
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
- Selenium::WebDriver::Wait.new(timeout: 5).until do
91
- driver.execute_script('return document.readyState') == 'complete'
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 capture(driver)
103
- file = Tempfile.new(['screenshot', '.png'])
104
- driver.save_screenshot(file.path)
105
- file.close
106
- file
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