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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +37 -0
- data/lib/trmnlp/browser_pool.rb +8 -8
- data/lib/trmnlp/cli.rb +7 -0
- data/lib/trmnlp/commands/test.rb +25 -0
- data/lib/trmnlp/config/project.rb +9 -6
- data/lib/trmnlp/config.rb +2 -2
- data/lib/trmnlp/context.rb +12 -4
- data/lib/trmnlp/errors.rb +1 -0
- data/lib/trmnlp/paths.rb +4 -2
- data/lib/trmnlp/poller.rb +20 -6
- data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
- data/lib/trmnlp/renderer.rb +4 -1
- data/lib/trmnlp/screenshot.rb +23 -9
- 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 +5 -4
- data/lib/trmnlp/transform_state.rb +4 -1
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp.rb +1 -0
- 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 +5 -0
- data/web/views/render_html.erb +1 -1
- metadata +62 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e31acf7d3493227af9ab7f728969de7c882622102661528186d29dce67ca722a
|
|
4
|
+
data.tar.gz: 29761cde0d07f5a026ff7a0bc3b1342ea6ad924402236296215bb6f8edfc2e29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
data/lib/trmnlp/browser_pool.rb
CHANGED
|
@@ -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 =
|
|
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
|
data/lib/trmnlp/context.rb
CHANGED
|
@@ -15,10 +15,15 @@ module TRMNLP
|
|
|
15
15
|
class Context
|
|
16
16
|
attr_reader :config, :paths, :reporter
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/trmnlp/renderer.rb
CHANGED
|
@@ -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."
|
data/lib/trmnlp/screenshot.rb
CHANGED
|
@@ -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
|
-
|
|
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
|