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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +10 -34
- data/db/data/form_fields.yml +1 -0
- data/db/data/framework_versions.yml +9 -1
- data/lib/trmnlp/commands/lint.rb +12 -1
- data/lib/trmnlp/config/project.rb +2 -0
- data/lib/trmnlp/context.rb +4 -2
- data/lib/trmnlp/firefox_driver.rb +3 -0
- data/lib/trmnlp/lint/diagnostic.rb +1 -3
- data/lib/trmnlp/lint.rb +2 -0
- data/lib/trmnlp/qr_code.rb +26 -0
- data/lib/trmnlp/screen_generator.rb +5 -3
- data/lib/trmnlp/screenshot.rb +45 -17
- data/lib/trmnlp/testing/browser.rb +23 -18
- data/lib/trmnlp/testing/page_server.rb +57 -0
- data/lib/trmnlp/testing/plugin.rb +22 -6
- data/lib/trmnlp/testing/publishable_recipe.rb +43 -3
- data/lib/trmnlp/testing/qr_scanner.rb +55 -0
- data/lib/trmnlp/testing/report.rb +1 -1
- data/lib/trmnlp/testing/rspec.rb +38 -1
- data/lib/trmnlp/testing/run.rb +1 -1
- data/lib/trmnlp/testing/screen.rb +74 -7
- data/lib/trmnlp/user_data_assembler.rb +6 -2
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp.rb +1 -1
- data/templates/init/tests/plugin_spec.rb +1 -1
- metadata +4 -3
- data/lib/trmnlp/qr_code_intrinsic_size.rb +0 -22
- data/lib/trmnlp/testing/inline_stylesheets.rb +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4db6e3e57f6772dd1af033e4ccb533ee516344b66d859b63185d23129a724ef5
|
|
4
|
+
data.tar.gz: 8415b8724cf0535880ab4c4e0a6ca42771363f7adae620fcca0bd290468a0d0c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
data/db/data/form_fields.yml
CHANGED
|
@@ -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.
|
|
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'
|
data/lib/trmnlp/commands/lint.rb
CHANGED
|
@@ -24,12 +24,23 @@ module TRMNLP
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def issues
|
|
27
|
-
@issues ||=
|
|
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.
|
data/lib/trmnlp/context.rb
CHANGED
|
@@ -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
|
@@ -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/)
|
data/lib/trmnlp/screenshot.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
104
|
-
document.
|
|
105
|
-
document.
|
|
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
|
-
|
|
134
|
+
Selenium::WebDriver::Wait.new(timeout: @fonts_timeout, interval: 0.05)
|
|
135
|
+
.until { driver.execute_script(FONTS_LOADED_SCRIPT) }
|
|
136
|
+
end
|
|
112
137
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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(
|
|
40
|
+
keep(generator(screen).process)
|
|
34
41
|
end
|
|
35
42
|
|
|
36
|
-
# ScreenGenerator's screenshot interface:
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
68
|
+
def generator(screen)
|
|
63
69
|
device = screen.device
|
|
64
|
-
html
|
|
65
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
87
|
-
html.sub(/<head>/i) { "<head><script>#{format(PAGE_CLOCK_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
|
|
24
|
-
expect(trmnl.render(view:, **screen, **recipe_inputs)).to
|
|
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
|
data/lib/trmnlp/testing/rspec.rb
CHANGED
|
@@ -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
|
|
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|
|
data/lib/trmnlp/testing/run.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
25
|
-
|
|
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, :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/trmnlp/version.rb
CHANGED
data/lib/trmnlp.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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/
|
|
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
|