trmnl_preview 0.18.1 → 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32924757751f8d15ad7979409c03f18906506af03558a90d27bfd122f0a44d0c
4
- data.tar.gz: 5cba7d9f2cbd67868316826218a1237efafafdc0f9967b3670ef538859c11f56
3
+ metadata.gz: 4db6e3e57f6772dd1af033e4ccb533ee516344b66d859b63185d23129a724ef5
4
+ data.tar.gz: 8415b8724cf0535880ab4c4e0a6ca42771363f7adae620fcca0bd290468a0d0c
5
5
  SHA512:
6
- metadata.gz: b9b8692c362797cd9df4ee23cf7a081051ec9f6ea18b28c721426fc58860231a5b02541c0a6823a8a940a33eb1a9e81f8fdcba9f3d43ad43f6871a880706fb58
7
- data.tar.gz: 1351106bc604919f20f0067a558eab0da46c1f08bf1cf7fd3ccce69db42dcac81136499d295629cfc64777b3f40586332701c312707b22348f56927611232fe0
6
+ metadata.gz: b440d6525409a971f262d09d7914d7be562dfadf617d1463c03b92519e848bd29b20e9e53fb30e7a1cf964919723e7e3e64364b573609ab7fbc2d215acdd97af
7
+ data.tar.gz: 96ce3caffe6bd63267aad0f6f9aa53a4471ddd6f308447e31040aed8968e77d3a4d82048ff44c4b80ccfbf4678284099f846c4d68f821effebd8bc53454637e2
data/CHANGELOG.md CHANGED
@@ -1,6 +1,32 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.20.0
5
+
6
+ - `trmnlp lint` skips the rules listed under `ignored_lint_rules` in `.trmnlp.yml`, in text and JSON output alike. An unknown rule ID is an error that lists the known ones.
7
+ - `trmnlp lint`, `build` and `serve` accept the `hidden` field type, which TRMNL renders as a hidden input. They warned "unknown field_type: hidden".
8
+ - The README lists `trmnlp test` under Commands, and the guide to testing plugins moved to `docs/testing.md`, with how to share setup through `tests/spec_helper.rb`.
9
+ - `trmnlp test`: `have_qr_code` on an element wholly off the left or top of the screen finds no code. It scanned part of the screen instead, because ImageMagick reads a crop 0 pixels wide as the whole width.
10
+ - `it_behaves_like 'a publishable recipe'` no longer checks overflow. `have_no_overflow` flags text cut short on purpose (`text-overflow: ellipsis`, `-webkit-line-clamp`) on most list and calendar recipes, so authors filtered it out; the matcher stays for examples that call it.
11
+ - `have_no_overflow` passes text cut short on purpose (`text-overflow: ellipsis`, `-webkit-line-clamp`) and text whose font is taller than its line, when only the empty space below it is cut. It reports a box only when a child box or a glyph's ink crosses its edge, so cut descenders still fail, and it now reports a scrolling box (`overflow: auto` or `scroll`) whose content is cut.
12
+ - `have_no_problems` ignores Firefox's "ResizeObserver loop completed with undelivered notifications", which it raises when a ResizeObserver resizes what it observes, as FullCalendar does. Browsers report it as a page error, but nothing is broken.
13
+ - `it_behaves_like 'a publishable recipe'` also uses the group's `variables` (for Plugin Merge recipes) and `now` when the group defines them. It passed only `mocks` and `custom_fields`, so such a recipe was drawn without its variables and on the real clock.
14
+ - `trmnlp test`: under `now:`, the page's clock starts at `now:` when the page runs. It started seconds late, by however long Firefox took to load the page. `Date()` without `new` no longer throws, and `Intl.DateTimeFormat#format` and `#formatToParts` given no date use `now:`.
15
+ - `trmnlp test --report` writes a report per process under parallel_tests (`report`, `report2`, ...) instead of each process overwriting the last. The README shows how to run a plugin's tests in parallel.
16
+ - `serve`, `build` and `test` skip resizing the page when it is already the size asked for, and check a resize every 0.01 s instead of 0.1 s. About 0.04 s faster a render.
17
+ - The bundled list of Framework versions goes up to 3.4.0, so offline, `trmnlp build` and `serve` no longer warn that a plugin pinned to 3.3.0 or later is not a published version. A release refreshes the list before it builds the gem.
18
+ - The `qr_code` filter draws a white border four modules wide inside the code's box, so it scans in dark mode and on a dark page. The box keeps its size and the modules shrink to make room.
19
+ - `trmnlp test`: `data:` reaches a static plugin. It was ignored there, so the test rendered the `static_data` in `settings.yml`.
20
+ - `trmnlp test` takes a screen's PNG only when a test asks for it (`match_snapshot`, `fit_image_size_limit`, `png_path`, `--report`). A test that only reads the page no longer waits for the screenshot and its quantizing, about 0.15 seconds a render. The picture is of the page when it is first asked for, so a page changed with `evaluate` before `match_snapshot` is drawn changed.
21
+ - `trmnlp test` opens each page from a local address instead of writing it into `about:blank` with its stylesheets inlined, so Firefox parses the Framework's 15 MB stylesheet once for the run: 10 renders take 5.5 seconds where they took 10.1, and trmnlp's own suite 89 seconds where it took 114. The page still gets no storage, cookies or `Referer`, as on TRMNL. Its `location` and the `Origin` of its cross-origin requests are `http://127.0.0.1:<port>`, and a relative URL is not found. (#193)
22
+ - Every screenshot is 0.1 to 0.2 s faster, in `test`, `build` and `serve`. Stopping the page's timers before the capture cleared 100,000 timer ids one by one; it now clears only the ids the page has used.
23
+ - A font file that never arrives no longer hangs a render until WebDriver's 30 second limit (`Selenium::WebDriver::Error::ScriptTimeoutError: Timed out after 30000 ms`), in `test`, `build` and `serve`. The page is given 10 seconds for its fonts, loaded once more, and then fails with the names of the fonts still loading.
24
+ - `have_qr_code` checks that a QR code scans in a screen's PNG, and with a String or Regexp what it reads; `within:` scans one element and `screen.qr_codes` lists every text. It uses zbar, which the Docker image now includes.
25
+
26
+ ## 0.19.0
27
+
28
+ - `it_behaves_like 'a publishable recipe'` also checks every screen for leaked values (`undefined`, `NaN`, `null`, `[object Object]`, `Liquid error`, raw `{{` or `{%`), draws the full view when the API answers with nothing, answers 500 or cannot be reached, and draws it with each option of every select field. New matchers: `have_no_leaked_text` and `have_no_transform_error`. (#181)
29
+
4
30
  ## 0.18.1
5
31
 
6
32
  - Pages narrower than a Firefox window render, such as a 480x800 TRMNL OG in portrait, in `serve`, `build` and `test`. trmnlp sets the page's viewport with WebDriver BiDi, as TRMNL does, instead of resizing the window, which Firefox will not size under about 500px. (#178)
data/README.md CHANGED
@@ -90,6 +90,7 @@ trmnlp push # upload
90
90
  | `trmnlp serve` | Start a local dev server |
91
91
  | `trmnlp build` | Generate static HTML files, or PNGs with `--png` |
92
92
  | `trmnlp lint` | Check plugin code against TRMNL best practices |
93
+ | `trmnlp test` | Run the plugin's tests in `tests/` |
93
94
  | `trmnlp login` | Authenticate with TRMNL server |
94
95
  | `trmnlp list` | List private plugins from TRMNL server |
95
96
  | `trmnlp clone NAME ID` | Copy a plugin project from TRMNL server |
@@ -123,6 +124,10 @@ Filters from `custom_filters` in `.trmnlp.yml` exist only in trmnlp. TRMNL does
123
124
  and outputs the value unfiltered, so `no_custom_filters` reports each place the markup uses one.
124
125
  Filters that trmnl-liquid also provides are not reported.
125
126
 
127
+ To accept a rule's findings, list its rule ID under `ignored_lint_rules` in `.trmnlp.yml`.
128
+ `trmnlp lint` then skips that check in both formats, so the CLI and CI agree. An unknown
129
+ rule ID is an error that lists the known ones, so a typo does not pass quietly.
130
+
126
131
  ## Building Static Files
127
132
 
128
133
  `trmnlp build` renders every view to a static file under `_build/` — handy for exporting a snapshot or feeding the output into another pipeline. Run it from inside a plugin project:
@@ -338,6 +343,10 @@ variables:
338
343
  plugin_settings:
339
344
  instance_name: Kevin Bacon Facts
340
345
 
346
+ # rule IDs whose findings `trmnlp lint` drops
347
+ ignored_lint_rules:
348
+ - no_opacity
349
+
341
350
  # plugin_merge strategy: the plugins this one reads, as "<keyname>_<id>" on TRMNL,
342
351
  # each mapped to the trmnlp project whose last fetched data stands in for it
343
352
  merged_plugins:
@@ -473,40 +482,7 @@ See [TRMNL documentation](https://help.trmnl.com/en/articles/10542599-importing-
473
482
 
474
483
  ## Testing Plugins
475
484
 
476
- `trmnlp init` starts a plugin with `tests/plugin_spec.rb` (`it_behaves_like 'a publishable recipe'`, below) 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
- - `it_behaves_like 'a publishable recipe'` is what a recipe should hold before it is published: every view draws without overflow or page errors on the TRMNL OG (1-bit, and 2-bit in landscape and portrait) and the TRMNL X (landscape and portrait), and the transform runs without error within TRMNL's limits. It uses the group's `mocks` and `custom_fields` when the group defines them; `screens: [{ device: 'kobo_libra_2' }, ...]` draws on other devices.
509
- - `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.
485
+ `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. `trmnlp init` starts a plugin with a test and a GitHub workflow that runs it. See [Testing Plugins](https://github.com/usetrmnl/trmnlp/blob/main/docs/testing.md) for the API, the matchers, snapshots and running tests in parallel.
510
486
 
511
487
  ## Development
512
488
 
@@ -19,6 +19,7 @@ field_types:
19
19
  - copyable_webhook_url
20
20
  - date
21
21
  - google_photos_picker
22
+ - hidden
22
23
  - json
23
24
  - lat_lon
24
25
  - modal_trigger
@@ -1,6 +1,6 @@
1
1
  # Mirrored from the TRMNL design-system source.
2
2
  # Refresh with `rake framework:sync` — do not edit manually.
3
- latest: 3.2.0
3
+ latest: 3.4.0
4
4
  versions:
5
5
  - number: 0.0.1
6
6
  released_at: '2024-06-22'
@@ -86,3 +86,11 @@ versions:
86
86
  released_at: '2026-07-23'
87
87
  - number: 3.2.0
88
88
  released_at: '2026-08-05'
89
+ - number: 3.3.0
90
+ released_at: '2026-08-27'
91
+ - number: 3.3.1
92
+ released_at: '2026-09-01'
93
+ - number: 3.3.2
94
+ released_at: '2026-09-18'
95
+ - number: 3.4.0
96
+ released_at: '2026-09-28'
@@ -24,12 +24,23 @@ module TRMNLP
24
24
  private
25
25
 
26
26
  def issues
27
- @issues ||= TRMNLP::Lint::CHECKS.flat_map do |type|
27
+ @issues ||= checks.flat_map do |type|
28
28
  check = type.new(source)
29
29
  check.issues.map { |finding| TRMNLP::Lint::Diagnostic.new(check, source, finding).to_h }
30
30
  end.uniq
31
31
  end
32
32
 
33
+ def checks
34
+ checks_by_rule_id = TRMNLP::Lint::CHECKS.to_h { |type| [TRMNLP::Lint.rule_id(type), type] }
35
+ unknown_rule_ids = config.project.ignored_lint_rules - checks_by_rule_id.keys
36
+ unless unknown_rule_ids.empty?
37
+ raise InvalidConfig, ".trmnlp.yml ignored_lint_rules has unknown rule IDs: #{unknown_rule_ids.join(', ')}. " \
38
+ "Known rule IDs: #{checks_by_rule_id.keys.join(', ')}"
39
+ end
40
+
41
+ checks_by_rule_id.except(*config.project.ignored_lint_rules).values
42
+ end
43
+
33
44
  def source
34
45
  @source ||= TRMNLP::Lint::Source.new(config:, paths:)
35
46
  end
@@ -54,6 +54,8 @@ module TRMNLP
54
54
 
55
55
  def time_zone = @config['time_zone'] || 'UTC'
56
56
 
57
+ def ignored_lint_rules = Array(@config['ignored_lint_rules'])
58
+
57
59
  # Local override for the framework asset host (offline / mirrored
58
60
  # dev). Trmnlp-specific (local dev only) — so it stays in
59
61
  # .trmnlp.yml. Consumed by Config::Plugin#framework_version.
@@ -18,12 +18,13 @@ module TRMNLP
18
18
  # The keywords after reporter let `trmnlp test` run the real pipeline with a test's inputs.
19
19
  # rubocop:disable-next Metrics/ParameterLists -- the composition root takes what it wires
20
20
  def initialize(root_dir, reporter: Reporter.new, cache_dir: nil, project_overrides: {}, outbound_request: nil,
21
- transform_client: nil)
21
+ transform_client: nil, source_data: nil)
22
22
  @paths = Paths.new(root_dir, cache_dir:)
23
23
  @config = Config.new(paths, project_overrides:)
24
24
  @reporter = reporter
25
25
  @outbound_request = outbound_request
26
26
  @transform_client = transform_client
27
+ @source_data = source_data
27
28
  end
28
29
 
29
30
  # Context is the composition root: it wires and memoizes the runtime
@@ -51,7 +52,8 @@ module TRMNLP
51
52
  end
52
53
 
53
54
  def user_data_assembler
54
- @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:, oauth_session:)
55
+ @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:, oauth_session:,
56
+ source_data: @source_data)
55
57
  end
56
58
 
57
59
  def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
@@ -18,6 +18,9 @@ module TRMNLP
18
18
  Selenium::WebDriver::Firefox::Options.new(web_socket_url: true).tap do |opts|
19
19
  opts.add_argument('--headless')
20
20
  opts.add_argument('--disable-web-security')
21
+ # A page trmnlp test serves from 127.0.0.1 gets no storage, cookies or Referer, as on TRMNL's about:blank.
22
+ opts.add_preference('network.cookie.cookieBehavior', 2)
23
+ opts.add_preference('network.http.sendRefererHeader', 0)
21
24
  end
22
25
  end
23
26
  end
@@ -33,9 +33,7 @@ module TRMNLP
33
33
 
34
34
  attr_reader :check, :source, :finding
35
35
 
36
- def rule_id
37
- check.class.name.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
38
- end
36
+ def rule_id = Lint.rule_id(check.class)
39
37
 
40
38
  def locations
41
39
  return source.yaml_location('src/settings.yml', SETTINGS_KEYS[rule_id]) if SETTINGS_KEYS.key?(rule_id)
data/lib/trmnlp/lint.rb CHANGED
@@ -43,5 +43,7 @@ module TRMNLP
43
43
  Checks::FormFieldsValid,
44
44
  Checks::NoCustomFilters
45
45
  ].freeze
46
+
47
+ def self.rule_id(check_type) = check_type.name.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
46
48
  end
47
49
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trmnl/liquid'
4
+
5
+ module TRMNLP
6
+ # Mirrors core's config/initializers/trmnl_liquid_qr_code.rb: a white quiet zone inside the code's size.
7
+ module QrCode
8
+ QUIET_ZONE_MODULES = 4
9
+
10
+ def qr_code(data, size = 11, level = '', view = 'responsive')
11
+ svg = super
12
+ side_length = svg[/<rect width="(\d+)"/, 1]
13
+ return svg unless side_length
14
+
15
+ border = size * QUIET_ZONE_MODULES
16
+ box = side_length.to_i + (2 * border)
17
+ sizing = %(width="#{side_length}" height="#{side_length}")
18
+ sizing += ' style="max-width:100%;height:auto"' if view == 'responsive'
19
+ svg.sub(/ (?:viewBox="[^"]*"|width="\d+" height="\d+")/, '')
20
+ .sub('<svg ', %(<svg #{sizing} viewBox="-#{border} -#{border} #{box} #{box}" ))
21
+ .sub(%r{<rect [^>]*?(?=/>)}, %(<rect width="#{box}" height="#{box}" x="-#{border}" y="-#{border}" fill="#fff"))
22
+ end
23
+ end
24
+ end
25
+
26
+ TRMNL::Liquid::Filters.prepend(TRMNLP::QrCode)
@@ -24,18 +24,20 @@ module TRMNLP
24
24
  @requested_color_depth = opts[:color_depth]
25
25
  end
26
26
 
27
+ # The page as it is shown: chart libraries swapped for TRMNL's copies.
28
+ def html = CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
29
+
27
30
  def process
28
- html = CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
29
31
  output = @screenshot.call(html:, width:, height:)
30
32
  ImageQuantizer.new(depth: color_depth, dither: @input.include?('image-dither')).call(output.path)
31
33
  output
32
34
  end
33
35
 
34
- private
35
-
36
36
  def width = @requested_width || 800
37
37
  def height = @requested_height || 480
38
38
 
39
+ private
40
+
39
41
  def color_depth
40
42
  return @requested_color_depth if @requested_color_depth
41
43
  return ::Regexp.last_match(1).to_i if @input&.match(/screen--(\d+)bit/)
@@ -15,21 +15,29 @@ module TRMNLP
15
15
  return document.readyState === 'complete' && window.TRMNL_PLUGINS_READY === true &&
16
16
  window.TRMNL_HIGHCHARTS_DONE === true && window.TRMNL_CHILD_DITHER_DONE !== false
17
17
  JS
18
- # Stops timers and animation callbacks from changing the page mid-capture.
18
+ # A font file that never arrives would keep the page waiting until WebDriver's own 30 second limit.
19
+ FONTS_TIMEOUT_SECONDS = 10
20
+ FONTS_LOADED_SCRIPT = "return !document.fonts || document.fonts.status === 'loaded'"
21
+ FONTS_LOADING_SCRIPT =
22
+ "return [...document.fonts].filter((font) => font.status === 'loading').map((font) => font.family)"
23
+ # Stops timers and animation callbacks from changing the page mid-capture. A window numbers its timeouts
24
+ # and intervals upward from one counter, so a new timer's id is the highest there is to clear.
19
25
  FREEZE_TIMERS = <<~JS
20
26
  const noop = () => 0;
27
+ const newest = window.setTimeout(noop, 0);
21
28
  window.setTimeout = window.setInterval = noop;
22
29
  window.requestAnimationFrame = noop;
23
30
  if (window.requestIdleCallback) window.requestIdleCallback = noop;
24
- for (let i = 100000; i >= 0; i--) { window.clearTimeout(i); window.clearInterval(i); }
31
+ for (let i = typeof newest === 'number' ? newest : 100000; i >= 0; i--) { window.clearTimeout(i); window.clearInterval(i); }
25
32
  if (window.ResizeObserver) window.ResizeObserver.prototype.observe = noop;
26
33
  if (window.MutationObserver) window.MutationObserver.prototype.observe = noop;
27
34
  window.onresize = null;
28
35
  JS
29
36
 
30
- def initialize(pool:, viewport_timeout: 5)
37
+ def initialize(pool:, viewport_timeout: 5, fonts_timeout: FONTS_TIMEOUT_SECONDS)
31
38
  @pool = pool
32
39
  @viewport_timeout = viewport_timeout
40
+ @fonts_timeout = fonts_timeout
33
41
  end
34
42
 
35
43
  def call(html:, width:, height:)
@@ -48,9 +56,10 @@ module TRMNLP
48
56
  # Loads html into driver at width x height and waits until TRMNL would capture it, and until wait_for
49
57
  # (a JavaScript expression) is true when given: a page still drawing would be stopped by the frozen timers.
50
58
  # 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)
59
+ # url: an address that serves html, opened in place of writing html into a blank page.
60
+ def show(driver, html, width, height, wait_for: nil, wait_for_timeout: READINESS_TIMEOUT_SECONDS, url: nil)
52
61
  resize(driver, width, height)
53
- load_page(driver, html) { wait_for_expression(driver, wait_for, wait_for_timeout) if wait_for }
62
+ load_page(driver, html, url:) { wait_for_expression(driver, wait_for, wait_for_timeout) if wait_for }
54
63
  end
55
64
 
56
65
  def capture(driver)
@@ -68,6 +77,8 @@ module TRMNLP
68
77
  end
69
78
 
70
79
  def resize(driver, width, height)
80
+ return if viewport(driver) == [width, height]
81
+
71
82
  set_viewport(driver, width, height)
72
83
  wait_for_viewport(driver, width, height)
73
84
  end
@@ -82,7 +93,7 @@ module TRMNLP
82
93
  # clipped the first screenshot short (800x433 instead of 800x480). Poll the
83
94
  # real viewport instead, re-applying the size until it lands.
84
95
  def wait_for_viewport(driver, width, height)
85
- Selenium::WebDriver::Wait.new(timeout: @viewport_timeout, interval: 0.1).until do
96
+ Selenium::WebDriver::Wait.new(timeout: @viewport_timeout, interval: 0.01).until do
86
97
  next true if viewport(driver) == [width, height]
87
98
 
88
99
  set_viewport(driver, width, height)
@@ -97,24 +108,41 @@ module TRMNLP
97
108
  driver.execute_script('return [window.innerWidth, window.innerHeight]')
98
109
  end
99
110
 
100
- def load_page(driver, html)
101
- driver.navigate.to('about:blank')
111
+ # A font that stalls is usually a dropped connection, so the page is loaded once more before giving up.
112
+ def load_page(driver, html, url: nil, &)
113
+ attempts = 0
114
+ begin
115
+ open_page(driver, html, url, &)
116
+ rescue Selenium::WebDriver::Error::TimeoutError
117
+ retry if (attempts += 1) <= 1
118
+ loading = driver.execute_script(FONTS_LOADING_SCRIPT).uniq.join(', ')
119
+ raise RenderError, "The page's fonts did not load within #{@fonts_timeout}s: #{loading}"
120
+ end
102
121
 
103
- driver.execute_script(<<~JS, html)
104
- document.open();
105
- document.write(arguments[0]);
106
- document.close();
122
+ driver.execute_script(<<~JS)
123
+ document.documentElement.style.overflow = 'hidden';
124
+ document.body.style.overflow = 'hidden';
107
125
  JS
126
+ driver.execute_script(FREEZE_TIMERS)
127
+ end
128
+
129
+ def open_page(driver, html, url)
130
+ url ? driver.navigate.to(url) : write_page(driver, html)
108
131
 
109
132
  wait_until_ready(driver, html)
110
133
  yield if block_given?
111
- driver.execute_script('return document.fonts && document.fonts.ready')
134
+ Selenium::WebDriver::Wait.new(timeout: @fonts_timeout, interval: 0.05)
135
+ .until { driver.execute_script(FONTS_LOADED_SCRIPT) }
136
+ end
112
137
 
113
- driver.execute_script(<<~JS)
114
- document.documentElement.style.overflow = 'hidden';
115
- document.body.style.overflow = 'hidden';
138
+ def write_page(driver, html)
139
+ driver.navigate.to('about:blank')
140
+
141
+ driver.execute_script(<<~JS, html)
142
+ document.open();
143
+ document.write(arguments[0]);
144
+ document.close();
116
145
  JS
117
- driver.execute_script(FREEZE_TIMERS)
118
146
  end
119
147
 
120
148
  def wait_for_expression(driver, expression, timeout)
@@ -7,15 +7,15 @@ require_relative '../browser_pool'
7
7
  require_relative '../firefox_driver'
8
8
  require_relative '../screen_generator'
9
9
  require_relative '../screenshot'
10
- require_relative 'inline_stylesheets'
11
10
 
12
11
  module TRMNLP
13
12
  module Testing
14
13
  # 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.
14
+ # Pages are drawn through ScreenGenerator and Screenshot like `trmnlp build --png`'s, but opened from page_server.
16
15
  class Browser
17
- def initialize(pool:)
16
+ def initialize(pool:, page_server:)
18
17
  @pool = pool
18
+ @page_server = page_server
19
19
  @screenshot = Screenshot.new(pool:)
20
20
  @pages = {}.compare_by_identity
21
21
  @fresh_browsers = []
@@ -24,21 +24,25 @@ module TRMNLP
24
24
  # A Firefox of its own, with nothing cached, closed when this browser is released.
25
25
  def fresh
26
26
  pool = BrowserPool.new(driver_factory: FirefoxDriver.method(:build), max_size: 1)
27
- self.class.new(pool:).tap { @fresh_browsers << [it, pool] }
27
+ self.class.new(pool:, page_server: @page_server).tap { @fresh_browsers << [it, pool] }
28
+ end
29
+
30
+ # Loads the screen's page; its picture is only taken when a test asks for it.
31
+ def load(screen)
32
+ generator = generator(screen)
33
+ @pages[screen] = [generator.html, generator.width, generator.height, @page_server.add(generator.html)]
34
+ show(screen)
28
35
  end
29
36
 
30
37
  # The PNG path, quantized to the device's bit depth like a build's.
31
38
  def capture(screen)
32
39
  @capturing = screen
33
- keep(generate(screen))
40
+ keep(generator(screen).process)
34
41
  end
35
42
 
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
43
+ # ScreenGenerator's screenshot interface: the page is already loaded, or loaded again if a later
44
+ # render replaced it.
45
+ def call(**) = on(@capturing) { @screenshot.capture(it) }
42
46
 
43
47
  # Yields the driver with screen's page loaded, reloading it if a later render replaced it.
44
48
  def on(screen)
@@ -52,6 +56,8 @@ module TRMNLP
52
56
  pool.shutdown
53
57
  end
54
58
  @fresh_browsers.clear
59
+ @pages.each_value { @page_server.forget(it.last) }
60
+ @pages.clear
55
61
  @pool.checkin(@driver) if @driver
56
62
  @driver = nil
57
63
  @showing = nil
@@ -59,19 +65,18 @@ module TRMNLP
59
65
 
60
66
  private
61
67
 
62
- def generate(screen)
68
+ def generator(screen)
63
69
  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
70
+ ScreenGenerator.new(screen.html, screenshot: self, width: device.width, height: device.height,
71
+ color_depth: device.bit_depth)
67
72
  end
68
73
 
69
74
  def driver = @driver ||= @pool.checkout
70
75
 
71
76
  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)
77
+ html, width, height, url = @pages.fetch(screen)
78
+ waits = { wait_for: screen.wait_for, wait_for_timeout: screen.wait_for_timeout }
79
+ @screenshot.show(driver, html, width, height, url:, **waits)
75
80
  @showing = screen
76
81
  end
77
82
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'socket'
5
+
6
+ module TRMNLP
7
+ module Testing
8
+ # Serves each page `trmnlp test` renders from one local address, so Firefox opens it as a page of its own.
9
+ # Written into about:blank, as TRMNL writes its page, every page has its stylesheets parsed again, 1.5 seconds
10
+ # for the Framework's; pages that share an address share the parsed copy, and a page's scripts wait for its
11
+ # stylesheets as on any site.
12
+ class PageServer
13
+ def initialize
14
+ @pages = {}
15
+ @lock = Mutex.new
16
+ end
17
+
18
+ def start
19
+ @server = TCPServer.new('127.0.0.1', 0)
20
+ @thread = Thread.new { loop { Thread.new(@server.accept) { serve(it) } } }
21
+ self
22
+ end
23
+
24
+ def stop
25
+ @thread&.kill
26
+ @server&.close
27
+ end
28
+
29
+ # The address html is served at until #forget.
30
+ def add(html)
31
+ id = SecureRandom.hex(8)
32
+ @lock.synchronize { @pages[id] = html }
33
+ "http://127.0.0.1:#{@server.addr[1]}/pages/#{id}"
34
+ end
35
+
36
+ def forget(url) = @lock.synchronize { @pages.delete(url.to_s.split('/').last) }
37
+
38
+ private
39
+
40
+ def serve(socket)
41
+ path = socket.gets.to_s.split[1].to_s
42
+ nil while (line = socket.gets) && !line.strip.empty?
43
+ html = @lock.synchronize { @pages[path[%r{\A/pages/(\h+)\z}, 1]] }
44
+ socket.write(html ? response('200 OK', html) : response('404 Not Found', ''))
45
+ rescue IOError, SystemCallError
46
+ nil
47
+ ensure
48
+ socket.close unless socket.closed?
49
+ end
50
+
51
+ def response(status, body)
52
+ "HTTP/1.1 #{status}\r\ncontent-type: text/html; charset=utf-8\r\ncache-control: no-store\r\n" \
53
+ "content-length: #{body.bytesize}\r\nconnection: close\r\n\r\n#{body}"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -18,6 +18,7 @@ module TRMNLP
18
18
  (() => {
19
19
  const problems = window.__trmnlpProblems = [];
20
20
  window.addEventListener('error', (event) => {
21
+ if (String(event.message).startsWith('ResizeObserver loop')) return;
21
22
  const source = event.target && event.target !== window && (event.target.src || event.target.href);
22
23
  problems.push(source ? `failed to load ${source}` :
23
24
  `${event.message}${event.filename ? ` (${event.filename}:${event.lineno})` : ''}`);
@@ -28,10 +29,25 @@ module TRMNLP
28
29
  console.error = (...parts) => { problems.push(`console.error: ${parts.join(' ')}`); consoleError(...parts); };
29
30
  })();
30
31
  JS
31
- PAGE_CLOCK_SCRIPT = '(() => { const offset = %d; const RealDate = Date; ' \
32
- 'class TestDate extends RealDate { constructor(...args) { args.length ? super(...args) : ' \
33
- 'super(RealDate.now() + offset); } static now() { return RealDate.now() + offset; } } ' \
34
- 'window.Date = TestDate; })();'
32
+ # Takes its offset when the page runs, so the time Firefox takes to load the page does not put it behind now:.
33
+ PAGE_CLOCK_SCRIPT = <<~JS.gsub(/\s+/, ' ').strip
34
+ (() => {
35
+ const RealDate = Date;
36
+ const offset = %d - RealDate.now();
37
+ class TestDate extends RealDate {
38
+ constructor(...args) { args.length ? super(...args) : super(RealDate.now() + offset); }
39
+ static now() { return RealDate.now() + offset; }
40
+ }
41
+ window.Date = new Proxy(TestDate, { apply: () => new TestDate().toString() });
42
+ const prototype = Intl.DateTimeFormat.prototype;
43
+ const format = Object.getOwnPropertyDescriptor(prototype, 'format').get;
44
+ const formatToParts = prototype.formatToParts;
45
+ Object.defineProperty(prototype, 'format', {
46
+ get() { const bound = format.call(this); return (date = TestDate.now()) => bound(date); }
47
+ });
48
+ prototype.formatToParts = function (date = TestDate.now()) { return formatToParts.call(this, date); };
49
+ })();
50
+ JS
35
51
 
36
52
  def initialize(dir, browser:, authority:)
37
53
  @dir = dir
@@ -83,8 +99,8 @@ module TRMNLP
83
99
  def with_page_clock(html, now)
84
100
  return html unless now
85
101
 
86
- offset = ((time(now).to_f - Time.now.to_f) * 1000).round
87
- html.sub(/<head>/i) { "<head><script>#{format(PAGE_CLOCK_SCRIPT, offset)}</script>" }
102
+ milliseconds = (time(now).to_f * 1000).round
103
+ html.sub(/<head>/i) { "<head><script>#{format(PAGE_CLOCK_SCRIPT, milliseconds)}</script>" }
88
104
  end
89
105
  end
90
106
  end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'yaml'
4
+
5
+ require_relative '../select_field_choices'
6
+
3
7
  module TRMNLP
4
8
  module Testing
5
9
  # The screens a recipe is drawn on: TRMNL's own devices, in landscape and portrait.
@@ -8,20 +12,56 @@ module TRMNLP
8
12
  { device: 'v2' }, { device: 'v2', orientation: :portrait }
9
13
  ].freeze
10
14
  PUBLISHABLE_RECIPE_VIEWS = %w[full half_horizontal half_vertical quadrant].freeze
15
+ PUBLISHABLE_RECIPE_API_FAILURES = {
16
+ 'answers with nothing' => { json: {} }, 'answers 500' => { status: 500, body: '' },
17
+ 'cannot be reached' => { error: :reset }
18
+ }.freeze
19
+ # A field with more options than this is drawn with its first and last.
20
+ PUBLISHABLE_RECIPE_MAX_OPTIONS = 20
21
+
22
+ # Each select field's keyname and the values its options save, from the plugin's settings.yml.
23
+ def self.select_field_values
24
+ path = File.join(plugin_dir, 'src', 'settings.yml')
25
+ fields = File.exist?(path) ? Array(YAML.safe_load_file(path, aliases: true)&.dig('custom_fields')) : []
26
+ fields.select { it['field_type'] == 'select' }.to_h do |field|
27
+ values = SelectFieldChoices.new(field).pairs.map { it.last.to_s }
28
+ [field['keyname'], values.size > PUBLISHABLE_RECIPE_MAX_OPTIONS ? values.values_at(0, -1) : values]
29
+ end
30
+ end
11
31
  end
12
32
  end
13
33
 
34
+ # rubocop:disable-next Metrics/BlockLength -- one shared group, one check per example
14
35
  RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLISHABLE_RECIPE_SCREENS|
15
36
  let(:recipe_inputs) do
16
- { mocks: respond_to?(:mocks) ? mocks : {}, custom_fields: respond_to?(:custom_fields) ? custom_fields : {} }
37
+ { mocks: respond_to?(:mocks) ? mocks : {}, custom_fields: respond_to?(:custom_fields) ? custom_fields : {},
38
+ variables: respond_to?(:variables) ? variables : {}, now: respond_to?(:now) ? now : nil }
17
39
  end
18
40
 
41
+ def draw_cleanly = have_no_problems.and(have_no_leaked_text)
42
+
19
43
  TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.product(screens).each do |view, screen|
20
44
  device = screen[:device].is_a?(Hash) ? screen[:device][:model] : screen[:device]
21
45
  screen_name = [device, screen[:orientation]].compact.join(' ')
22
46
 
23
- it "draws the #{view} view on #{screen_name} without overflow or page errors" do
24
- expect(trmnl.render(view:, **screen, **recipe_inputs)).to have_no_overflow.and have_no_problems
47
+ it "draws the #{view} view on #{screen_name} without page errors" do
48
+ expect(trmnl.render(view:, **screen, **recipe_inputs)).to draw_cleanly
49
+ end
50
+ end
51
+
52
+ TRMNLP::Testing::PUBLISHABLE_RECIPE_API_FAILURES.each do |failure, answer|
53
+ it "draws the full view when the API #{failure}" do
54
+ screen = trmnl.render(**screens.first, **recipe_inputs, mocks: { '*' => answer })
55
+ expect(screen).to have_no_problems.and(have_no_leaked_text).and(have_no_transform_error)
56
+ end
57
+ end
58
+
59
+ TRMNLP::Testing.select_field_values.each do |keyname, values|
60
+ values.each do |value|
61
+ it "draws the full view with #{keyname} set to #{value}" do
62
+ custom_fields = recipe_inputs[:custom_fields].merge(keyname => value)
63
+ expect(trmnl.render(**screens.first, **recipe_inputs, custom_fields:)).to draw_cleanly
64
+ end
25
65
  end
26
66
  end
27
67
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mini_magick'
4
+ require 'nokogiri'
5
+ require 'open3'
6
+ require 'tempfile'
7
+
8
+ module TRMNLP
9
+ module Testing
10
+ # Reads the QR codes in a PNG the way a phone would, with zbar's `zbarimg`.
11
+ class QrScanner
12
+ COMMAND = 'zbarimg'
13
+ MISSING = 'Scanning a QR code needs zbar (`brew install zbar` or `apt-get install zbar-tools`; ' \
14
+ 'already in the Docker image)'
15
+
16
+ def initialize(path, area: nil)
17
+ @path = path
18
+ @area = area
19
+ end
20
+
21
+ # The text of every code found, in the order zbar reports them.
22
+ def texts = @area ? cropped { scan(it) } : scan(@path)
23
+
24
+ private
25
+
26
+ def scan(path)
27
+ xml, error, status = Open3.capture3(COMMAND, '--quiet', '--xml', '-Sdisable', '-Sqrcode.enable', path)
28
+ # zbarimg exits 4 when the picture has no code.
29
+ raise TestingError, "zbarimg could not scan #{path}: #{error.strip}" unless [0, 4].include?(status.exitstatus)
30
+
31
+ Nokogiri::XML(xml).remove_namespaces!.xpath('//symbol/data').map(&:text)
32
+ rescue Errno::ENOENT
33
+ raise TestingError, MISSING
34
+ end
35
+
36
+ def cropped
37
+ return [] unless (geometry = crop_geometry)
38
+
39
+ Tempfile.create(['trmnlp-qr-', '.png']) do |file|
40
+ MiniMagick::Image.open(@path).crop(geometry).write(file.path)
41
+ yield file.path
42
+ end
43
+ end
44
+
45
+ def crop_geometry
46
+ left = [@area.left.floor, 0].max
47
+ top = [@area.top.floor, 0].max
48
+ width = @area.right.ceil - left
49
+ height = @area.bottom.ceil - top
50
+ # ImageMagick reads a 0 width or height as the whole picture, so an area off it has nothing to crop.
51
+ "#{width}x#{height}+#{left}+#{top}" if width.positive? && height.positive?
52
+ end
53
+ end
54
+ end
55
+ end
@@ -25,7 +25,7 @@ module TRMNLP
25
25
  end
26
26
 
27
27
  def initialize(dir)
28
- @dir = dir
28
+ @dir = "#{dir}#{ENV.fetch('TEST_ENV_NUMBER', '')}" # parallel_tests: report, report2, report3...
29
29
  @examples = {}
30
30
  @images = 0
31
31
  end
@@ -10,6 +10,7 @@ require_relative '../browser_pool'
10
10
  require_relative '../firefox_driver'
11
11
  require_relative 'browser'
12
12
  require_relative 'certificate_authority'
13
+ require_relative 'page_server'
13
14
  require_relative 'plugin'
14
15
  require_relative 'report'
15
16
  require_relative 'publishable_recipe'
@@ -18,6 +19,8 @@ require_relative 'snapshot'
18
19
  module TRMNLP
19
20
  module Testing
20
21
  PLUGIN_DIR_ENV_KEY = 'TRMNLP_PLUGIN_DIR'
22
+ # What a template or script draws when its data is missing.
23
+ LEAKED_TEXT_PATTERN = /Liquid (?:syntax )?error|\bundefined\b|\bNaN\b|\bnull\b|\[object Object\]|\{\{|\{%/
21
24
 
22
25
  def self.plugin_dir = ENV.fetch(PLUGIN_DIR_ENV_KEY, Dir.pwd)
23
26
 
@@ -29,10 +32,16 @@ module TRMNLP
29
32
  @browser_pool ||= BrowserPool.new(driver_factory: FirefoxDriver.method(:build), max_size: 1)
30
33
  end
31
34
 
35
+ def self.page_server
36
+ @page_server ||= PageServer.new.start.tap { |server| at_exit { server.stop } }
37
+ end
38
+
32
39
  module Helpers
33
40
  def trmnl = @trmnl ||= Plugin.new(Testing.plugin_dir, browser: trmnl_browser, authority: Testing.authority)
34
41
 
35
- def trmnl_browser = @trmnl_browser ||= Browser.new(pool: Testing.browser_pool)
42
+ def trmnl_browser
43
+ @trmnl_browser ||= Browser.new(pool: Testing.browser_pool, page_server: Testing.page_server)
44
+ end
36
45
  end
37
46
  end
38
47
  end
@@ -53,11 +62,39 @@ RSpec::Matchers.define :stay_within_serverless_limits do
53
62
  end
54
63
  end
55
64
 
65
+ RSpec::Matchers.define :have_no_leaked_text do
66
+ match { |screen| screen.text.scan(TRMNLP::Testing::LEAKED_TEXT_PATTERN).empty? }
67
+ failure_message do |screen|
68
+ leaked = screen.text.scan(TRMNLP::Testing::LEAKED_TEXT_PATTERN).uniq
69
+ "expected no leaked values on #{screen.inspect}, but it shows: #{leaked.join(', ')}"
70
+ end
71
+ end
72
+
73
+ RSpec::Matchers.define :have_no_transform_error do
74
+ match { |screen| screen.result.error.nil? }
75
+ failure_message { |screen| "expected the transform to run without error, but: #{screen.result.error}" }
76
+ end
77
+
56
78
  RSpec::Matchers.define :have_no_problems do
57
79
  match { |screen| screen.problems.empty? }
58
80
  failure_message { |screen| "expected no problems on #{screen.inspect}, but: #{screen.problems.join('; ')}" }
59
81
  end
60
82
 
83
+ # A QR code that scans in the PNG; with a String or Regexp, one that reads as it.
84
+ RSpec::Matchers.define :have_qr_code do |text = nil, within: nil|
85
+ match do |screen|
86
+ @found = screen.qr_codes(within:)
87
+ text ? @found.any? { text === it } : @found.any? # rubocop:disable Style/CaseEquality -- a String or a Regexp
88
+ end
89
+ failure_message do |screen|
90
+ wanted = text ? "a QR code reading #{text.inspect}" : 'a QR code that scans'
91
+ place = within ? " in #{within}" : ''
92
+ "expected #{wanted}#{place} on #{screen.inspect}, but " \
93
+ "#{@found.empty? ? 'none scans' : "found #{@found.map(&:inspect).join(', ')}"}"
94
+ end
95
+ failure_message_when_negated { |screen| "expected no QR code on #{screen.inspect}, but found #{@found.inspect}" }
96
+ end
97
+
61
98
  RSpec::Matchers.define :fit_image_size_limit do
62
99
  match { |screen| screen.png_bytes.bytesize <= screen.device.image_size_limit.to_i }
63
100
  failure_message do |screen|
@@ -75,7 +75,7 @@ module TRMNLP
75
75
  def build_context(cache_dir, proxy)
76
76
  transform_client = inputs[:transform] ? transform_client(proxy) : nil
77
77
  context = Context.new(plugin, reporter:, cache_dir:, project_overrides:, outbound_request: table,
78
- transform_client:)
78
+ transform_client:, source_data: inputs[:data] && stringify(inputs[:data]))
79
79
  seed(context.paths)
80
80
  context
81
81
  end
@@ -4,6 +4,8 @@ require 'capybara'
4
4
  require 'delegate'
5
5
  require 'nokogiri'
6
6
 
7
+ require_relative 'qr_scanner'
8
+
7
9
  module TRMNLP
8
10
  module Testing
9
11
  # A rendered view, as Firefox drew it. Capybara's finders and matchers (have_text, have_css, within...)
@@ -11,19 +13,76 @@ module TRMNLP
11
13
  class Screen < SimpleDelegator
12
14
  Box = Data.define(:left, :top, :right, :bottom, :width, :height)
13
15
 
14
- # Every element drawn past the screen's edge or cut off inside a box that hides its overflow.
16
+ # Every element drawn past the screen's edge, and every box that cuts a child box or a glyph's ink.
15
17
  OVERFLOW_SCRIPT = <<~JS
16
18
  const screen = document.documentElement.getBoundingClientRect();
17
19
  const describe = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
18
20
  (el.classList.length ? '.' + [...el.classList].join('.') : '');
21
+ const clips = (el) => getComputedStyle(el).overflow !== 'visible';
22
+ const truncatesOnPurpose = (style) => style.textOverflow === 'ellipsis' || style.webkitLineClamp !== 'none';
23
+ const drawn = (rect) => rect.width > 0 || rect.height > 0;
24
+ const insideTruncation = (el) => {
25
+ for (let parent = el.parentElement; parent; parent = parent.parentElement) {
26
+ if (truncatesOnPurpose(getComputedStyle(parent))) return true;
27
+ }
28
+ return false;
29
+ };
30
+ const clippedOnlyBy = (node, el) => {
31
+ for (let parent = node.parentElement; parent !== el; parent = parent.parentElement) {
32
+ if (clips(parent)) return false;
33
+ }
34
+ return true;
35
+ };
36
+ const inlineBox = (el) => getComputedStyle(el).display === 'inline' &&
37
+ !(el instanceof HTMLImageElement || el instanceof SVGSVGElement || el instanceof HTMLCanvasElement);
38
+ const textMeasuringContext = document.createElement('canvas').getContext('2d');
39
+ const glyphInk = (glyph, rect) => {
40
+ const metrics = textMeasuringContext.measureText(glyph);
41
+ const scale = rect.height / (metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent);
42
+ const baseline = rect.top + metrics.fontBoundingBoxAscent * scale;
43
+ return { left: rect.left - metrics.actualBoundingBoxLeft * scale,
44
+ right: rect.left + metrics.actualBoundingBoxRight * scale,
45
+ top: baseline - metrics.actualBoundingBoxAscent * scale,
46
+ bottom: baseline + metrics.actualBoundingBoxDescent * scale };
47
+ };
48
+ const glyphRects = (el) => {
49
+ const found = [];
50
+ const range = document.createRange();
51
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
52
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
53
+ if (!node.data.trim() || !clippedOnlyBy(node, el) || node.parentElement instanceof SVGElement) continue;
54
+ const style = getComputedStyle(node.parentElement);
55
+ textMeasuringContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
56
+ for (let start = 0; start < node.data.length;) {
57
+ const end = start + String.fromCodePoint(node.data.codePointAt(start)).length;
58
+ const glyph = node.data.slice(start, end);
59
+ if (glyph.trim()) {
60
+ range.setStart(node, start);
61
+ range.setEnd(node, end);
62
+ [...range.getClientRects()].filter(drawn).forEach((rect) => found.push(glyphInk(glyph, rect)));
63
+ }
64
+ start = end;
65
+ }
66
+ }
67
+ return found;
68
+ };
69
+ const cutsChildOrGlyph = (el) => {
70
+ const box = el.getBoundingClientRect();
71
+ const left = box.left + el.clientLeft;
72
+ const top = box.top + el.clientTop;
73
+ const crossesBoxEdge = (rect) => rect.left < left - 1 || rect.top < top - 1 ||
74
+ rect.right > left + el.clientWidth + 1 || rect.bottom > top + el.clientHeight + 1;
75
+ const children = [...el.querySelectorAll('*')].filter((child) => clippedOnlyBy(child, el) && !inlineBox(child))
76
+ .map((child) => child.getBoundingClientRect()).filter(drawn);
77
+ return children.some(crossesBoxEdge) || glyphRects(el).some(crossesBoxEdge);
78
+ };
19
79
  return [...document.querySelectorAll('.view *')].filter((el) => {
20
80
  const box = el.getBoundingClientRect();
21
- if (box.width === 0 && box.height === 0) return false;
81
+ if (!drawn(box) || insideTruncation(el)) return false;
22
82
  const outside = box.right > screen.right + 1 || box.bottom > screen.bottom + 1 || box.left < -1 || box.top < -1;
23
83
  const style = getComputedStyle(el);
24
- const clips = ['hidden', 'clip'].includes(style.overflowX) || ['hidden', 'clip'].includes(style.overflowY);
25
- const cut = clips && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1);
26
- return outside || cut;
84
+ const overflows = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
85
+ return outside || (clips(el) && !truncatesOnPurpose(style) && overflows && cutsChildOrGlyph(el));
27
86
  }).map(describe);
28
87
  JS
29
88
 
@@ -39,7 +98,7 @@ module TRMNLP
39
98
  return copy.outerHTML;
40
99
  JS
41
100
 
42
- attr_reader :html, :device, :view, :png_path, :result, :wait_for, :wait_for_timeout
101
+ attr_reader :html, :device, :view, :result, :wait_for, :wait_for_timeout
43
102
 
44
103
  # rubocop:disable-next Metrics/ParameterLists -- what a render produced and how to wait for it
45
104
  def initialize(html:, device:, view:, browser:, result:, wait_for: nil, wait_for_timeout: 5)
@@ -50,7 +109,7 @@ module TRMNLP
50
109
  @view = view
51
110
  @browser = browser
52
111
  @result = result
53
- @png_path = browser.capture(self)
112
+ browser.load(self)
54
113
  super(Capybara.string(drawn_dom(browser.on(self) { it.execute_script(DRAWN_DOM_SCRIPT) })))
55
114
  end
56
115
 
@@ -69,8 +128,16 @@ module TRMNLP
69
128
  # Script errors, unhandled rejections, console.error lines and files that failed to load.
70
129
  def problems = evaluate('window.__trmnlpProblems || []')
71
130
 
131
+ # The screenshot, quantized for the device. It is taken the first time it is asked for, so a test that
132
+ # only reads the page does not wait for a picture.
133
+ def png_path = @png_path ||= @browser.capture(self)
134
+
72
135
  def png_bytes = File.binread(png_path)
73
136
 
137
+ # The text of each QR code that scans in the PNG, so on what the device shows; within: a selector
138
+ # limits the scan to that element.
139
+ def qr_codes(within: nil) = QrScanner.new(png_path, area: within && box(within)).texts
140
+
74
141
  def drawn_dom(source)
75
142
  document = Nokogiri::HTML(source)
76
143
  document.css("[#{HIDDEN_MARK}]").each { it['hidden'] = 'hidden' }
@@ -12,7 +12,9 @@ module TRMNLP
12
12
  DEFAULT_DEVICE_WIDTH = 800
13
13
  DEFAULT_DEVICE_HEIGHT = 480
14
14
 
15
- def initialize(config:, paths:, transform_pipeline:, oauth_session: nil)
15
+ # source_data: a test's own data, in place of what the plugin's strategy would read.
16
+ def initialize(config:, paths:, transform_pipeline:, oauth_session: nil, source_data: nil)
17
+ @given_source_data = source_data
16
18
  @config = config
17
19
  @paths = paths
18
20
  @transform_pipeline = transform_pipeline
@@ -73,7 +75,9 @@ module TRMNLP
73
75
  def previous_merge_variables = config.plugin.webhook? ? source_data : transform_pipeline.previous_output
74
76
 
75
77
  def source_data
76
- if config.plugin.static?
78
+ if @given_source_data
79
+ @given_source_data
80
+ elsif config.plugin.static?
77
81
  config.plugin.static_data
78
82
  elsif config.plugin.plugin_merge?
79
83
  MergedPluginLocals.new(config:, paths:).call
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.18.1'
4
+ VERSION = '0.20.0'
5
5
  end
data/lib/trmnlp.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module TRMNLP; end
4
4
  require 'oj'
5
5
  require 'trmnl/liquid'
6
- require_relative 'trmnlp/qr_code_intrinsic_size'
6
+ require_relative 'trmnlp/qr_code'
7
7
  Oj.mimic_JSON
8
8
  require_relative 'trmnlp/rails_helpers'
9
9
  require_relative 'trmnlp/errors'
@@ -10,7 +10,7 @@ RSpec.describe 'My Plugin' do
10
10
  # The values of the custom fields in src/settings.yml to test with.
11
11
  let(:custom_fields) { {} }
12
12
 
13
- # Every view on TRMNL's devices without overflow or page errors, and the transform within TRMNL's limits.
13
+ # Every view on TRMNL's devices, a failing API and every select option, checked for page errors and leaked values.
14
14
  it_behaves_like 'a publishable recipe'
15
15
 
16
16
  # A picture of the full view, compared pixel for pixel on every run. Record it once with
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trmnl_preview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.1
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -450,7 +450,7 @@ files:
450
450
  - lib/trmnlp/outbound_request.rb
451
451
  - lib/trmnlp/paths.rb
452
452
  - lib/trmnlp/poller.rb
453
- - lib/trmnlp/qr_code_intrinsic_size.rb
453
+ - lib/trmnlp/qr_code.rb
454
454
  - lib/trmnlp/rails_helpers.rb
455
455
  - lib/trmnlp/renderer.rb
456
456
  - lib/trmnlp/reporter.rb
@@ -462,13 +462,14 @@ files:
462
462
  - lib/trmnlp/testing/certificate_authority.rb
463
463
  - lib/trmnlp/testing/device_models.rb
464
464
  - lib/trmnlp/testing/frozen_clock.rb
465
- - lib/trmnlp/testing/inline_stylesheets.rb
466
465
  - lib/trmnlp/testing/memory_sampler.rb
467
466
  - lib/trmnlp/testing/mock_proxy.rb
468
467
  - lib/trmnlp/testing/mock_table.rb
469
468
  - lib/trmnlp/testing/node_version.rb
469
+ - lib/trmnlp/testing/page_server.rb
470
470
  - lib/trmnlp/testing/plugin.rb
471
471
  - lib/trmnlp/testing/publishable_recipe.rb
472
+ - lib/trmnlp/testing/qr_scanner.rb
472
473
  - lib/trmnlp/testing/report.html.erb
473
474
  - lib/trmnlp/testing/report.rb
474
475
  - lib/trmnlp/testing/rspec.rb
@@ -1,22 +0,0 @@
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)
@@ -1,44 +0,0 @@
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
- SCRIPT = %r{<script\b.*?</script>}im
14
- HREF = /\bhref=["'](https?:[^"']+)["']/i
15
- CSS_URL = /url\(\s*(['"]?)(?!data:|https?:|#)([^'")]+)\1\s*\)/i
16
-
17
- module_function
18
-
19
- def call(html)
20
- html.gsub(Regexp.union(SCRIPT, LINK)) do |tag|
21
- next tag if tag.match?(/\A<script/i)
22
-
23
- url = tag[HREF, 1]
24
- css = url && stylesheet(url)
25
- css ? "<style data-href=\"#{url}\">#{css}</style>" : tag
26
- end
27
- end
28
-
29
- # nil when the stylesheet cannot be fetched: the page keeps its link.
30
- def stylesheet(url)
31
- cache.fetch(url) do
32
- response = Faraday.get(url)
33
- cache[url] = response.success? ? absolute_urls(response.body, url) : nil
34
- end
35
- rescue Faraday::Error
36
- nil
37
- end
38
-
39
- def absolute_urls(css, base) = css.gsub(CSS_URL) { "url(\"#{URI.join(base, Regexp.last_match(2))}\")" }
40
-
41
- def cache = @cache ||= {}
42
- end
43
- end
44
- end