trmnl_preview 0.16.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +37 -0
  4. data/lib/trmnlp/browser_pool.rb +8 -8
  5. data/lib/trmnlp/cli.rb +7 -0
  6. data/lib/trmnlp/commands/test.rb +25 -0
  7. data/lib/trmnlp/config/project.rb +9 -6
  8. data/lib/trmnlp/config.rb +2 -2
  9. data/lib/trmnlp/context.rb +12 -4
  10. data/lib/trmnlp/errors.rb +1 -0
  11. data/lib/trmnlp/paths.rb +4 -2
  12. data/lib/trmnlp/poller.rb +20 -6
  13. data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
  14. data/lib/trmnlp/renderer.rb +4 -1
  15. data/lib/trmnlp/screenshot.rb +23 -9
  16. data/lib/trmnlp/testing/browser.rb +87 -0
  17. data/lib/trmnlp/testing/certificate_authority.rb +84 -0
  18. data/lib/trmnlp/testing/device_models.rb +80 -0
  19. data/lib/trmnlp/testing/frozen_clock.rb +86 -0
  20. data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
  21. data/lib/trmnlp/testing/memory_sampler.rb +44 -0
  22. data/lib/trmnlp/testing/mock_proxy.rb +124 -0
  23. data/lib/trmnlp/testing/mock_table.rb +102 -0
  24. data/lib/trmnlp/testing/node_version.rb +27 -0
  25. data/lib/trmnlp/testing/plugin.rb +91 -0
  26. data/lib/trmnlp/testing/report.html.erb +79 -0
  27. data/lib/trmnlp/testing/report.rb +91 -0
  28. data/lib/trmnlp/testing/rspec.rb +89 -0
  29. data/lib/trmnlp/testing/run.rb +150 -0
  30. data/lib/trmnlp/testing/screen.rb +83 -0
  31. data/lib/trmnlp/testing/snapshot.rb +55 -0
  32. data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
  33. data/lib/trmnlp/transform_pipeline.rb +5 -4
  34. data/lib/trmnlp/transform_state.rb +4 -1
  35. data/lib/trmnlp/version.rb +1 -1
  36. data/lib/trmnlp.rb +1 -0
  37. data/templates/init/.github/workflows/trmnl.yml +33 -2
  38. data/templates/init/.gitignore +3 -0
  39. data/templates/init/tests/plugin_spec.rb +32 -0
  40. data/trmnl_preview.gemspec +5 -0
  41. data/web/views/render_html.erb +1 -1
  42. metadata +62 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2460473710692cd80df450f411bdf811e44a9da068600b45db871c9e00ce0651
4
- data.tar.gz: b34619a0fccac692176089ae44fe45c4c040a04bb2ca7035031750f768adc73d
3
+ metadata.gz: e31acf7d3493227af9ab7f728969de7c882622102661528186d29dce67ca722a
4
+ data.tar.gz: 29761cde0d07f5a026ff7a0bc3b1342ea6ad924402236296215bb6f8edfc2e29
5
5
  SHA512:
6
- metadata.gz: 8de469fd1f6f81023ac8b3ad25168dce59f68a731a5814fc845e8a6155996e0800a7d5871e0d89fbd009bc910ad6b497f5a4dc6186025f60a79f758df31aaab1
7
- data.tar.gz: b0bf8c9737e32bb67b3489de471c29ce78b03a4a40fcef69916834477d5daa0d3a4a89dcdaca68e411f20a1cafb5e91142216ae85c2e3d3a6e42a94f5fe944cf
6
+ metadata.gz: c2a64c19c1fcbdd43c49f94e604e4e0f136f46469abf42a96d2c993f9594f2f4ba1801b3c243d71edc97dfc59e1801b882ee288fcc0184cd638e5917abbe1286
7
+ data.tar.gz: 40a4779d9e8694389ee0d087ec03081171a930c72341aee959e2cbea37ca1ef095006a8bc311ab4dd2bf0fd610f682fe5afbb93792c247c2f9f1b4ba7bc884c2
data/CHANGELOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.17.0
5
+
6
+ - `trmnlp init` adds a starter `tests/plugin_spec.rb`, and its GitHub workflow gains a `test` job: `trmnlp test --report` in the `trmnl/trmnlp` image (one `TRMNLP_IMAGE` setting picks the tag), the report uploaded, a manual `update_snapshots` run, and the push to TRMNL waiting for lint and tests. (#168)
7
+ - `trmnlp test --report DIR` writes a report of the run: every example with each screen it rendered (the PNG, an outline of every drawn box, the page's problems) and each transform it ran (time, memory, requests), as `DIR/index.html` and `DIR/report.json`. Under GitHub Actions it adds the counts and the failures to the run's summary. (#167)
8
+ - A polling plugin with no polling url runs its transform on empty data, as TRMNL does, instead of warning "config must specify polling_url" and counting it as a failed fetch, which stopped the transform's state being saved. A plugin whose transform fetches everything itself has no polling url. (#166)
9
+ - `trmnlp test` runs a plugin's RSpec files in `tests/` through trmnlp's own pipeline: `trmnl.transform` and `trmnl.render` with fake APIs (the transform's own HTTPS requests included, in any language), a fixed clock (libfaketime, macOS and Linux), saved state, any TRMNL device, Capybara's matchers on what Firefox drew, layout boxes, overflow checks and PNG snapshots. See the README's Testing Plugins section. (#165)
10
+ - A responsive `qr_code` keeps its own size, as on TRMNL. The hosted service gives the SVG a width, a height and `max-width:100%;height:auto` (core's `trmnl_liquid_qr_code_intrinsic_size` initializer), and trmnlp now does the same. Without it, a code in a shrink-to-fit container such as a `flex flex--col` column shrank to the width of its neighbors, or to nothing. (#162)
11
+ - The Framework script loads as a module, as on TRMNL. A module runs after the page is parsed, so a plugin script that calls the Framework straight away now fails locally the way it fails on the device, instead of only on the device. (#163)
12
+ - A transform's `trmnl_state` is not saved when the poll before it failed, as on TRMNL: a state built from an error or an empty response would replace the last good one. The key is still kept out of the rendered data. (#163)
13
+
4
14
  ## 0.16.0
5
15
 
6
16
  - Webhook posts must wrap their data in `merge_variables`, as TRMNL requires, and `merge_strategy` (`replace`, `deep_merge`, or `stream` with `stream_limit`) works from the body or the query string. A post without the wrapper is refused with TRMNL's message and a 422; a body that is not JSON gets a 400. Before, trmnlp stored the raw body and always answered 200, so a correct post rendered its data under `merge_variables.*`. A post over 5 kB is stored with a warning, since trmnlp cannot know your plan's limit. (#155)
data/README.md CHANGED
@@ -470,6 +470,43 @@ The `settings.yml` file is part of the plugin definition, and is uploaded and do
470
470
  See [TRMNL documentation](https://help.trmnl.com/en/articles/10542599-importing-and-exporting-private-plugins#h_581fb988f0) for details on this file's contents.
471
471
 
472
472
 
473
+
474
+ ## Testing Plugins
475
+
476
+ `trmnlp init` starts a plugin with `tests/plugin_spec.rb` (each view draws without overflow or page errors, and the transform stays within TRMNL's limits) and a GitHub workflow that runs it in the `trmnl/trmnlp` image, uploads the report, can rewrite the snapshots on a manual run, and pushes to TRMNL only once lint and tests pass. `trmnlp test` runs the RSpec files in your plugin's `tests/` folder, through the same pipeline `serve` and `build` use, with fake APIs and a fixed clock:
477
+
478
+ ```ruby
479
+ # tests/weather_spec.rb
480
+ RSpec.describe 'Weather' do
481
+ let(:mocks) { { 'https://api.weather.example/*' => { json: { temp: 12 } } } }
482
+
483
+ %w[og_plus v2].each do |device|
484
+ it "shows the temperature on #{device}" do
485
+ screen = trmnl.render(device:, now: '2030-01-02T08:00:00Z', custom_fields: { city: 'Amsterdam' }, mocks:)
486
+
487
+ expect(screen).to have_text('12°')
488
+ expect(screen).to have_no_overflow
489
+ expect(screen.box('.title').bottom).to be <= screen.box('.content').top
490
+ expect(screen).to match_snapshot
491
+ end
492
+ end
493
+
494
+ it 'keeps its state when the API has nothing new' do
495
+ run = trmnl.transform(state: { etag: 'abc' }, mocks: { 'https://api.weather.example/*' => { status: 304 } })
496
+
497
+ expect(run.state).to eq('etag' => 'abc')
498
+ end
499
+ end
500
+ ```
501
+
502
+ - `trmnl.transform(...)` runs the transform and answers `data`, `state`, `requests`, `log`, `error`, `duration_ms` and `max_memory_mb`; `expect(run).to stay_within_serverless_limits` checks TRMNL's 5 seconds and 128 MB. `trmnl.render(view: 'full', ...)` renders a view in Firefox and answers a screen: Capybara's matchers (`have_text`, `have_css`, `within`...) see what is drawn, and `box(selector)`, `evaluate(js)`, `overflowing` and `problems` (script errors, unhandled rejections, `console.error` and files that failed to load; `have_no_problems`) ask the live page; `fresh_browser: true` renders in a new Firefox with nothing cached; `screen.result` holds the run's `data`, `state` and `requests`. `trmnl.plugin(dir)` tests the plugin in another folder, such as a built copy.
503
+ - Inputs, all optional: `device:` (a TRMNL model name, or `{ width:, height:, bit_depth: }`), `palette:`, `orientation: :portrait`, `dark_mode:`, `theme:`, `now:`, `custom_fields:`, `variables:`, `state:`, `previous_merge_variables:`, `data:` (skips polling), `transform: false`. A test's `custom_fields` and `variables` replace those in `.trmnlp.yml`, so development values there never reach a test; the field defaults in `settings.yml` still apply, as on TRMNL.
504
+ - A board drawn by its own script takes `head:` (markup added to the page's `<head>`) and `wait_for:` (a JavaScript expression the page must reach before it is captured, within `wait_for_timeout:` seconds); without it, the capture freezes timers once TRMNL's readiness flags are set.
505
+ - `mocks:` answer every request the run makes: polling urls, and the transform's own requests in any language, HTTPS included. Keys are urls, with `*` wildcards, a Regexp, or a method first (`'POST https://...'`). Values are `{ json:, body:, status:, headers:, delay:, body_delay:, advance_clock:, error: :reset }` (times in seconds: `body_delay:` sends the headers first and the body later, `advance_clock:` moves the transform's clock on when it answers), a string body, a lambda that takes the request, or an array of answers used in order. An unmocked request gets a 599. `requests` lists every request with its `status`, and `aborted: true` for one the transform gave up on before its answer arrived; `duration_ms` is how long the transform ran.
506
+ - `now:` starts every clock: Liquid's, the markup's scripts', and the transform's, through libfaketime (`brew install libfaketime` or `apt-get install libfaketime`; already in the Docker image). On macOS the interpreter must come from brew, mise or similar, since macOS will not hand libfaketime to its own `/usr/bin` binaries.
507
+ - `trmnlp test --report report` also writes `report/index.html` and `report/report.json`: every example with each screen it rendered (with a switch that outlines every drawn box, and the page's problems) and each transform it ran (time, memory, requests). Under GitHub Actions the counts and failures go to the run's summary too.
508
+ - `match_snapshot` stores a missing snapshot under `tests/snapshots/<os>/` and fails one on CI; `trmnlp test --update` rewrites them. Fonts render differently per operating system, so run tests in the Docker image when CI should share your snapshots. `fit_image_size_limit` checks the PNG against the model's limit.
509
+
473
510
  ## Development
474
511
 
475
512
  To run trmnlp from a checkout of this repo — handy for trying unreleased changes or contributing:
@@ -34,13 +34,19 @@ module TRMNLP
34
34
  end
35
35
  end
36
36
 
37
- private
38
-
39
37
  def checkout
40
38
  driver = acquire
41
39
  healthy?(driver) ? driver : recycle(driver)
42
40
  end
43
41
 
42
+ def checkin(driver)
43
+ return if @shutdown
44
+
45
+ @available.push(driver)
46
+ end
47
+
48
+ private
49
+
44
50
  def acquire
45
51
  pop_available || build_new || @available.pop
46
52
  end
@@ -72,11 +78,5 @@ module TRMNLP
72
78
  @driver_factory.call.tap { |d| @drivers << d }
73
79
  end
74
80
  end
75
-
76
- def checkin(driver)
77
- return if @shutdown
78
-
79
- @available.push(driver)
80
- end
81
81
  end
82
82
  end
data/lib/trmnlp/cli.rb CHANGED
@@ -86,6 +86,13 @@ module TRMNLP
86
86
  Commands::Serve.run(options)
87
87
  end
88
88
 
89
+ desc 'test [PATHS]', "Run the plugin's tests in tests/ (RSpec, with `trmnl` and screen matchers)"
90
+ method_option :update, type: :boolean, default: false, desc: 'Rewrite the stored snapshots'
91
+ method_option :report, type: :string, banner: 'DIR', desc: 'Write a report of every screen and transform to DIR'
92
+ def test(*paths)
93
+ exit(1) unless Commands::Test.run(options, paths)
94
+ end
95
+
89
96
  desc 'version', 'Show version'
90
97
  def version
91
98
  puts VERSION
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module TRMNLP
6
+ module Commands
7
+ # Runs the plugin's RSpec files (tests/**/*_spec.rb) with `trmnl` and the screen matchers loaded.
8
+ class Test < Base
9
+ Options = Data.define(:dir, :quiet, :update, :report)
10
+ HELPERS = File.expand_path('../testing/rspec.rb', __dir__)
11
+
12
+ def call(paths = [])
13
+ context.validate!
14
+ require 'rspec/core'
15
+
16
+ ENV['TRMNLP_PLUGIN_DIR'] = context.paths.root_dir.to_s
17
+ ENV['TRMNLP_UPDATE_SNAPSHOTS'] = '1' if options.update
18
+ ENV['TRMNLP_REPORT_DIR'] = File.expand_path(options.report) if options.report
19
+ Dir.chdir(context.paths.root_dir) do
20
+ RSpec::Core::Runner.run(['--require', HELPERS, *(paths.empty? ? ['tests'] : paths)]).zero?
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -11,17 +11,14 @@ module TRMNLP
11
11
  class Project
12
12
  attr_reader :paths
13
13
 
14
- def initialize(paths)
14
+ def initialize(paths, overrides: {})
15
15
  @paths = paths
16
+ @overrides = overrides
16
17
  reload!
17
18
  end
18
19
 
19
20
  def reload!
20
- @config = if paths.trmnlp_config.exist?
21
- YAML.safe_load_file(paths.trmnlp_config, permitted_classes: [Date, Time]) || {}
22
- else
23
- {}
24
- end
21
+ @config = read_config.merge(@overrides)
25
22
  rescue Psych::SyntaxError => e
26
23
  raise InvalidConfig, ".trmnlp.yml is not valid YAML: #{e.message}"
27
24
  end
@@ -90,6 +87,12 @@ module TRMNLP
90
87
 
91
88
  private
92
89
 
90
+ def read_config
91
+ return {} unless paths.trmnlp_config.exist?
92
+
93
+ YAML.safe_load_file(paths.trmnlp_config, permitted_classes: [Date, Time]) || {}
94
+ end
95
+
93
96
  # NOTE: arrays (multi-select fields) and hashes are preserved as-is;
94
97
  # only their leaf values are stringified to match production
95
98
  # behavior.
data/lib/trmnlp/config.rb CHANGED
@@ -8,9 +8,9 @@ module TRMNLP
8
8
  class Config
9
9
  attr_reader :app, :project, :plugin
10
10
 
11
- def initialize(path)
11
+ def initialize(path, project_overrides: {})
12
12
  @app = App.new(path)
13
- @project = Project.new(path)
13
+ @project = Project.new(path, overrides: project_overrides)
14
14
  @plugin = Plugin.new(path, @project)
15
15
  end
16
16
  end
@@ -15,10 +15,15 @@ module TRMNLP
15
15
  class Context
16
16
  attr_reader :config, :paths, :reporter
17
17
 
18
- def initialize(root_dir, reporter: Reporter.new)
19
- @paths = Paths.new(root_dir)
20
- @config = Config.new(paths)
18
+ # The keywords after reporter let `trmnlp test` run the real pipeline with a test's inputs.
19
+ # rubocop:disable-next Metrics/ParameterLists -- the composition root takes what it wires
20
+ def initialize(root_dir, reporter: Reporter.new, cache_dir: nil, project_overrides: {}, outbound_request: nil,
21
+ transform_client: nil)
22
+ @paths = Paths.new(root_dir, cache_dir:)
23
+ @config = Config.new(paths, project_overrides:)
21
24
  @reporter = reporter
25
+ @outbound_request = outbound_request
26
+ @transform_client = transform_client
22
27
  end
23
28
 
24
29
  # Context is the composition root: it wires and memoizes the runtime
@@ -26,6 +31,7 @@ module TRMNLP
26
31
  # directly — Context does not forward methods on their behalf.
27
32
  def poller
28
33
  @poller ||= Poller.new(config:, paths:, oauth_session:, reporter:, async_callback:,
34
+ outbound_request: @outbound_request,
29
35
  trmnl_variables: -> { user_data_assembler.polling_variables })
30
36
  end
31
37
 
@@ -40,7 +46,9 @@ module TRMNLP
40
46
  end
41
47
  end
42
48
 
43
- def transform_pipeline = @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:)
49
+ def transform_pipeline
50
+ @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:, client: @transform_client)
51
+ end
44
52
 
45
53
  def user_data_assembler
46
54
  @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:, oauth_session:)
data/lib/trmnlp/errors.rb CHANGED
@@ -13,4 +13,5 @@ module TRMNLP
13
13
  class InvalidConfig < Error; end
14
14
  class InvalidCustomFields < Error; end
15
15
  class RenderError < Error; end
16
+ class TestingError < Error; end
16
17
  end
data/lib/trmnlp/paths.rb CHANGED
@@ -7,8 +7,9 @@ module TRMNLP
7
7
  class Paths
8
8
  attr_reader :root_dir
9
9
 
10
- def initialize(root_dir)
10
+ def initialize(root_dir, cache_dir: nil)
11
11
  @root_dir = Pathname.new(root_dir).expand_path
12
+ @cache_dir = cache_dir && Pathname.new(cache_dir)
12
13
  @xdg = XDG.new
13
14
  end
14
15
 
@@ -27,7 +28,7 @@ module TRMNLP
27
28
 
28
29
  def app_config_dir = xdg.config_home.join('trmnlp')
29
30
 
30
- def cache_dir = xdg.cache_home.join('trmnl')
31
+ def cache_dir = @cache_dir || xdg.cache_home.join('trmnl')
31
32
 
32
33
  def valid? = trmnlp_config.exist?
33
34
 
@@ -50,6 +51,7 @@ module TRMNLP
50
51
  def oauth_tokens = cache_dir.join('oauth', "#{project_key}.json")
51
52
 
52
53
  def transform_state = cache_dir.join('state', "#{project_key}.json")
54
+ def fetch_failed_marker = cache_dir.join('state', "#{project_key}.fetch_failed")
53
55
 
54
56
  def transform_output = cache_dir.join('transform_output', "#{project_key}.json")
55
57
 
data/lib/trmnlp/poller.rb CHANGED
@@ -14,8 +14,9 @@ module TRMNLP
14
14
  # trmnl_variables answers the trmnl namespace TRMNL renders polling urls, headers and bodies with.
15
15
  # rubocop:disable-next Metrics/ParameterLists -- keyword collaborators Context wires once
16
16
  def initialize(config:, paths:, oauth_session:, trmnl_variables: -> { {} }, reporter: Reporter.new,
17
- async_callback: nil)
17
+ async_callback: nil, outbound_request: nil)
18
18
  @config = config
19
+ @given_outbound_request = outbound_request
19
20
  @paths = paths
20
21
  @oauth_session = oauth_session
21
22
  @trmnl_variables = trmnl_variables
@@ -29,16 +30,19 @@ module TRMNLP
29
30
 
30
31
  missing = config.plugin.missing_required_fields
31
32
  return report_warning("Plugin is not configured — fill in: #{missing.join(', ')}") if missing.any?
32
- raise InvalidConfig, 'config must specify polling_url or polling_urls' if config.plugin.polling_urls.empty?
33
33
 
34
- data = aggregate_responses
34
+ @fetch_failed = false
35
+ # Like TRMNL: no url is nothing to fetch, and the transform runs on {}.
36
+ data = config.plugin.polling_urls.empty? ? {} : aggregate_responses
35
37
  write_user_data(data)
38
+ record_fetch_outcome
36
39
  data
37
40
  # NOTE: trmnlp is a dev tool — a flaky upstream API should surface a warning
38
41
  # and keep the preview server alive, not crash the user's session. We
39
42
  # deliberately swallow here and return {} so the renderer keeps rendering.
40
43
  rescue StandardError => e
41
44
  report_warning(e.message)
45
+ record_fetch_outcome
42
46
  {}
43
47
  end
44
48
 
@@ -52,7 +56,7 @@ module TRMNLP
52
56
  return report_warning('async_polling needs `trmnlp serve` to receive the callback')
53
57
  end
54
58
  return report_warning('Waiting for the async callback; the last data posted renders') if async_callback.awaiting?
55
- raise InvalidConfig, 'config must specify polling_url' if config.plugin.polling_urls.empty?
59
+ return if config.plugin.polling_urls.empty?
56
60
 
57
61
  report_warning('Async callback not received within 15 minutes') if async_callback.expired?
58
62
  response, url = send_async_request(async_callback.start)
@@ -99,7 +103,7 @@ module TRMNLP
99
103
  end
100
104
 
101
105
  def outbound_request
102
- OutboundRequest.new(on_private_address: lambda { |url|
106
+ @given_outbound_request || OutboundRequest.new(on_private_address: lambda { |url|
103
107
  report_warning("TRMNL refuses #{without_query(url)}: the url resolves to a private address")
104
108
  })
105
109
  end
@@ -107,7 +111,7 @@ module TRMNLP
107
111
  # Like the hosted service: a client error is reported, but its body still reaches the data.
108
112
  def parse_response(response, url)
109
113
  success = (200..299).cover?(response.status)
110
- reporter.info(reporter.yellow("warning: HTTP #{response.status} from #{url}")) unless success
114
+ report_warning("HTTP #{response.status} from #{url}") unless success
111
115
  parse_body(without_byte_order_mark(response.body.to_s), response.headers['content-type'])
112
116
  rescue JSON::ParserError, CSV::MalformedCSVError => e
113
117
  report_warning("Malformed #{e.is_a?(CSV::MalformedCSVError) ? 'CSV' : 'JSON'} from url: #{url}")
@@ -144,10 +148,20 @@ module TRMNLP
144
148
  def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
145
149
 
146
150
  def report_warning(message)
151
+ @fetch_failed = true
147
152
  reporter.info(reporter.yellow("warning: #{message}"))
148
153
  nil
149
154
  end
150
155
 
156
+ # The transform runs later, from the cached data, so whether this poll failed is kept beside it.
157
+ def record_fetch_outcome
158
+ marker = paths.fetch_failed_marker
159
+ return marker.delete if !@fetch_failed && marker.exist?
160
+
161
+ marker.dirname.mkpath
162
+ marker.write('') if @fetch_failed
163
+ end
164
+
151
165
  def write_user_data(data)
152
166
  paths.user_data.dirname.mkpath
153
167
  paths.user_data.write(JSON.generate(data))
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trmnl/liquid'
4
+
5
+ module TRMNLP
6
+ # Mirrors core's config/initializers/trmnl_liquid_qr_code_intrinsic_size.rb: a view box alone
7
+ # gives the code no size of its own, so it renders at zero width in a shrink-to-fit container.
8
+ module QrCodeIntrinsicSize
9
+ def qr_code(data, size = 11, level = '', view = 'responsive')
10
+ svg = super
11
+ root_element = svg[/<svg[^>]*>/].to_s
12
+ return svg if root_element.include?('width=')
13
+
14
+ side_length = root_element[/viewBox="0 0 (\d+)/, 1]
15
+ return svg unless side_length
16
+
17
+ svg.sub('<svg ', %(<svg width="#{side_length}" height="#{side_length}" style="max-width:100%;height:auto" ))
18
+ end
19
+ end
20
+ end
21
+
22
+ TRMNL::Liquid::Filters.prepend(TRMNLP::QrCodeIntrinsicSize)
@@ -29,6 +29,9 @@ module TRMNLP
29
29
 
30
30
  def framework = config.plugin.framework_version
31
31
 
32
+ # The data the last page was rendered from, for a test to read back.
33
+ attr_reader :last_data
34
+
32
35
  def screen_classes(classes = 'screen', theme: nil)
33
36
  classes = (classes || 'screen').split # an explicit nil (omitted screen_classes param) still needs a base
34
37
  # The picker marks every render 1x, which sets the dither ratio; TRMNL's device render has no such class.
@@ -66,7 +69,7 @@ module TRMNLP
66
69
  end
67
70
 
68
71
  def parse_and_render(template_path, device:)
69
- data = user_data_assembler.call(device:)
72
+ data = @last_data = user_data_assembler.call(device:)
70
73
  size = JSON.generate(data.except('trmnl')).bytesize
71
74
  if size > MAX_MERGE_VARIABLES_KB * 1024
72
75
  raise RenderError, "Large payload received (#{size} bytes), should be less than #{MAX_MERGE_VARIABLES_KB}kb."
@@ -45,11 +45,25 @@ module TRMNLP
45
45
  end
46
46
  end
47
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
+
48
63
  private
49
64
 
50
65
  def render(driver, html, width, height)
51
- resize(driver, width, height)
52
- load_page(driver, html)
66
+ show(driver, html, width, height)
53
67
  capture(driver)
54
68
  end
55
69
 
@@ -108,6 +122,7 @@ module TRMNLP
108
122
  JS
109
123
 
110
124
  wait_until_ready(driver, html)
125
+ yield if block_given?
111
126
  driver.execute_script('return document.fonts && document.fonts.ready')
112
127
 
113
128
  driver.execute_script(<<~JS)
@@ -117,18 +132,17 @@ module TRMNLP
117
132
  driver.execute_script(FREEZE_TIMERS)
118
133
  end
119
134
 
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
+
120
141
  def wait_until_ready(driver, html)
121
142
  timeout = html.match?(MAPS_DOCUMENT_PATTERN) ? READINESS_TIMEOUT_WITH_MAPS_SECONDS : READINESS_TIMEOUT_SECONDS
122
143
  Selenium::WebDriver::Wait.new(timeout:, interval: 0.05).until { driver.execute_script(READINESS_CHECK_SCRIPT) }
123
144
  rescue Selenium::WebDriver::Error::TimeoutError
124
145
  nil
125
146
  end
126
-
127
- def capture(driver)
128
- file = Tempfile.new(['screenshot', '.png'])
129
- driver.save_screenshot(file.path)
130
- file.close
131
- file
132
- end
133
147
  end
134
148
  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