trmnl_preview 0.19.0 → 0.21.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 +32 -0
- data/README.md +39 -36
- data/db/data/form_fields.yml +1 -0
- data/db/data/framework_versions.yml +9 -1
- data/lib/trmnlp/cli.rb +22 -0
- 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/checks/no_unknown_filters.rb +46 -0
- data/lib/trmnlp/lint/diagnostic.rb +3 -5
- data/lib/trmnlp/lint.rb +5 -1
- data/lib/trmnlp/paths.rb +3 -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 +4 -3
- data/lib/trmnlp/testing/qr_scanner.rb +55 -0
- data/lib/trmnlp/testing/report.rb +1 -1
- data/lib/trmnlp/testing/rspec.rb +26 -4
- data/lib/trmnlp/testing/run.rb +1 -1
- data/lib/trmnlp/testing/screen.rb +76 -8
- data/lib/trmnlp/update_check.rb +94 -0
- 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/bin/trmnlp +70 -21
- data/templates/init/tests/plugin_spec.rb +1 -1
- metadata +6 -3
- data/lib/trmnlp/qr_code_intrinsic_size.rb +0 -22
- data/lib/trmnlp/testing/inline_stylesheets.rb +0 -44
|
@@ -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
|
|
@@ -34,16 +34,17 @@ end
|
|
|
34
34
|
# rubocop:disable-next Metrics/BlockLength -- one shared group, one check per example
|
|
35
35
|
RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLISHABLE_RECIPE_SCREENS|
|
|
36
36
|
let(:recipe_inputs) do
|
|
37
|
-
{ 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 }
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
def draw_cleanly =
|
|
41
|
+
def draw_cleanly = have_no_problems.and(have_no_leaked_text)
|
|
41
42
|
|
|
42
43
|
TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.product(screens).each do |view, screen|
|
|
43
44
|
device = screen[:device].is_a?(Hash) ? screen[:device][:model] : screen[:device]
|
|
44
45
|
screen_name = [device, screen[:orientation]].compact.join(' ')
|
|
45
46
|
|
|
46
|
-
it "draws the #{view} view on #{screen_name} without
|
|
47
|
+
it "draws the #{view} view on #{screen_name} without page errors" do
|
|
47
48
|
expect(trmnl.render(view:, **screen, **recipe_inputs)).to draw_cleanly
|
|
48
49
|
end
|
|
49
50
|
end
|
|
@@ -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'
|
|
@@ -31,18 +32,24 @@ module TRMNLP
|
|
|
31
32
|
@browser_pool ||= BrowserPool.new(driver_factory: FirefoxDriver.method(:build), max_size: 1)
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
def self.page_server
|
|
36
|
+
@page_server ||= PageServer.new.start.tap { |server| at_exit { server.stop } }
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
module Helpers
|
|
35
40
|
def trmnl = @trmnl ||= Plugin.new(Testing.plugin_dir, browser: trmnl_browser, authority: Testing.authority)
|
|
36
41
|
|
|
37
|
-
def trmnl_browser
|
|
42
|
+
def trmnl_browser
|
|
43
|
+
@trmnl_browser ||= Browser.new(pool: Testing.browser_pool, page_server: Testing.page_server)
|
|
44
|
+
end
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
RSpec::Matchers.define :have_no_overflow do
|
|
43
|
-
match { |screen| screen.overflowing.empty? }
|
|
49
|
+
RSpec::Matchers.define :have_no_overflow do |except: nil|
|
|
50
|
+
match { |screen| screen.overflowing(except:).empty? }
|
|
44
51
|
failure_message do |screen|
|
|
45
|
-
"expected nothing to overflow on #{screen.inspect}, but: #{screen.overflowing.join(', ')}"
|
|
52
|
+
"expected nothing to overflow on #{screen.inspect}, but: #{screen.overflowing(except:).join(', ')}"
|
|
46
53
|
end
|
|
47
54
|
end
|
|
48
55
|
|
|
@@ -73,6 +80,21 @@ RSpec::Matchers.define :have_no_problems do
|
|
|
73
80
|
failure_message { |screen| "expected no problems on #{screen.inspect}, but: #{screen.problems.join('; ')}" }
|
|
74
81
|
end
|
|
75
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
|
+
|
|
76
98
|
RSpec::Matchers.define :fit_image_size_limit do
|
|
77
99
|
match { |screen| screen.png_bytes.bytesize <= screen.device.image_size_limit.to_i }
|
|
78
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,77 @@ 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
|
|
18
|
+
const except = arguments[0];
|
|
16
19
|
const screen = document.documentElement.getBoundingClientRect();
|
|
17
20
|
const describe = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
|
|
18
21
|
(el.classList.length ? '.' + [...el.classList].join('.') : '');
|
|
22
|
+
const clips = (el) => getComputedStyle(el).overflow !== 'visible';
|
|
23
|
+
const truncatesOnPurpose = (style) => style.textOverflow === 'ellipsis' || style.webkitLineClamp !== 'none';
|
|
24
|
+
const drawn = (rect) => rect.width > 0 || rect.height > 0;
|
|
25
|
+
const insideTruncation = (el) => {
|
|
26
|
+
for (let parent = el.parentElement; parent; parent = parent.parentElement) {
|
|
27
|
+
if (truncatesOnPurpose(getComputedStyle(parent))) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
31
|
+
const clippedOnlyBy = (node, el) => {
|
|
32
|
+
for (let parent = node.parentElement; parent !== el; parent = parent.parentElement) {
|
|
33
|
+
if (clips(parent)) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
const inlineBox = (el) => getComputedStyle(el).display === 'inline' &&
|
|
38
|
+
!(el instanceof HTMLImageElement || el instanceof SVGSVGElement || el instanceof HTMLCanvasElement);
|
|
39
|
+
const textMeasuringContext = document.createElement('canvas').getContext('2d');
|
|
40
|
+
const glyphInk = (glyph, rect) => {
|
|
41
|
+
const metrics = textMeasuringContext.measureText(glyph);
|
|
42
|
+
const scale = rect.height / (metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent);
|
|
43
|
+
const baseline = rect.top + metrics.fontBoundingBoxAscent * scale;
|
|
44
|
+
return { left: rect.left - metrics.actualBoundingBoxLeft * scale,
|
|
45
|
+
right: rect.left + metrics.actualBoundingBoxRight * scale,
|
|
46
|
+
top: baseline - metrics.actualBoundingBoxAscent * scale,
|
|
47
|
+
bottom: baseline + metrics.actualBoundingBoxDescent * scale };
|
|
48
|
+
};
|
|
49
|
+
const glyphRects = (el) => {
|
|
50
|
+
const found = [];
|
|
51
|
+
const range = document.createRange();
|
|
52
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
53
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
54
|
+
if (!node.data.trim() || !clippedOnlyBy(node, el) || node.parentElement instanceof SVGElement) continue;
|
|
55
|
+
const style = getComputedStyle(node.parentElement);
|
|
56
|
+
textMeasuringContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
57
|
+
for (let start = 0; start < node.data.length;) {
|
|
58
|
+
const end = start + String.fromCodePoint(node.data.codePointAt(start)).length;
|
|
59
|
+
const glyph = node.data.slice(start, end);
|
|
60
|
+
if (glyph.trim()) {
|
|
61
|
+
range.setStart(node, start);
|
|
62
|
+
range.setEnd(node, end);
|
|
63
|
+
[...range.getClientRects()].filter(drawn).forEach((rect) => found.push(glyphInk(glyph, rect)));
|
|
64
|
+
}
|
|
65
|
+
start = end;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
};
|
|
70
|
+
const cutsChildOrGlyph = (el) => {
|
|
71
|
+
const box = el.getBoundingClientRect();
|
|
72
|
+
const left = box.left + el.clientLeft;
|
|
73
|
+
const top = box.top + el.clientTop;
|
|
74
|
+
const crossesBoxEdge = (rect) => rect.left < left - 1 || rect.top < top - 1 ||
|
|
75
|
+
rect.right > left + el.clientWidth + 1 || rect.bottom > top + el.clientHeight + 1;
|
|
76
|
+
const children = [...el.querySelectorAll('*')].filter((child) => clippedOnlyBy(child, el) && !inlineBox(child))
|
|
77
|
+
.map((child) => child.getBoundingClientRect()).filter(drawn);
|
|
78
|
+
return children.some(crossesBoxEdge) || glyphRects(el).some(crossesBoxEdge);
|
|
79
|
+
};
|
|
19
80
|
return [...document.querySelectorAll('.view *')].filter((el) => {
|
|
20
81
|
const box = el.getBoundingClientRect();
|
|
21
|
-
if (box
|
|
82
|
+
if (!drawn(box) || insideTruncation(el) || (except && el.closest(except))) return false;
|
|
22
83
|
const outside = box.right > screen.right + 1 || box.bottom > screen.bottom + 1 || box.left < -1 || box.top < -1;
|
|
23
84
|
const style = getComputedStyle(el);
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
return outside || cut;
|
|
85
|
+
const overflows = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
|
|
86
|
+
return outside || (clips(el) && !truncatesOnPurpose(style) && overflows && cutsChildOrGlyph(el));
|
|
27
87
|
}).map(describe);
|
|
28
88
|
JS
|
|
29
89
|
|
|
@@ -39,7 +99,7 @@ module TRMNLP
|
|
|
39
99
|
return copy.outerHTML;
|
|
40
100
|
JS
|
|
41
101
|
|
|
42
|
-
attr_reader :html, :device, :view, :
|
|
102
|
+
attr_reader :html, :device, :view, :result, :wait_for, :wait_for_timeout
|
|
43
103
|
|
|
44
104
|
# rubocop:disable-next Metrics/ParameterLists -- what a render produced and how to wait for it
|
|
45
105
|
def initialize(html:, device:, view:, browser:, result:, wait_for: nil, wait_for_timeout: 5)
|
|
@@ -50,7 +110,7 @@ module TRMNLP
|
|
|
50
110
|
@view = view
|
|
51
111
|
@browser = browser
|
|
52
112
|
@result = result
|
|
53
|
-
|
|
113
|
+
browser.load(self)
|
|
54
114
|
super(Capybara.string(drawn_dom(browser.on(self) { it.execute_script(DRAWN_DOM_SCRIPT) })))
|
|
55
115
|
end
|
|
56
116
|
|
|
@@ -64,13 +124,21 @@ module TRMNLP
|
|
|
64
124
|
|
|
65
125
|
def evaluate(expression) = @browser.on(self) { it.execute_script("return (#{expression});") }
|
|
66
126
|
|
|
67
|
-
def overflowing = @browser.on(self) { it.execute_script(OVERFLOW_SCRIPT) }
|
|
127
|
+
def overflowing(except: nil) = @browser.on(self) { it.execute_script(OVERFLOW_SCRIPT, except) }
|
|
68
128
|
|
|
69
129
|
# Script errors, unhandled rejections, console.error lines and files that failed to load.
|
|
70
130
|
def problems = evaluate('window.__trmnlpProblems || []')
|
|
71
131
|
|
|
132
|
+
# The screenshot, quantized for the device. It is taken the first time it is asked for, so a test that
|
|
133
|
+
# only reads the page does not wait for a picture.
|
|
134
|
+
def png_path = @png_path ||= @browser.capture(self)
|
|
135
|
+
|
|
72
136
|
def png_bytes = File.binread(png_path)
|
|
73
137
|
|
|
138
|
+
# The text of each QR code that scans in the PNG, so on what the device shows; within: a selector
|
|
139
|
+
# limits the scan to that element.
|
|
140
|
+
def qr_codes(within: nil) = QrScanner.new(png_path, area: within && box(within)).texts
|
|
141
|
+
|
|
74
142
|
def drawn_dom(source)
|
|
75
143
|
document = Nokogiri::HTML(source)
|
|
76
144
|
document.css("[#{HIDDEN_MARK}]").each { it['hidden'] = 'hidden' }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'reporter'
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
|
|
8
|
+
module TRMNLP
|
|
9
|
+
# Tells the user when RubyGems has a newer trmnl_preview. The answer is
|
|
10
|
+
# cached for a day so routine commands do not wait on the network; a
|
|
11
|
+
# failed lookup is cached too, so an offline run stalls at most once a day.
|
|
12
|
+
class UpdateCheck
|
|
13
|
+
ENDPOINT = 'https://rubygems.org/api/v1/gems/trmnl_preview.json'
|
|
14
|
+
TIMEOUT = 2
|
|
15
|
+
CACHE_TTL = 24 * 60 * 60
|
|
16
|
+
DISABLE_ENV_KEY = 'TRMNLP_NO_UPDATE_NOTIFIER'
|
|
17
|
+
|
|
18
|
+
def self.disabled? = !ENV[DISABLE_ENV_KEY].to_s.empty?
|
|
19
|
+
|
|
20
|
+
def initialize(cache_path, reporter: Reporter.new(stream: $stderr))
|
|
21
|
+
@cache_path = Pathname.new(cache_path)
|
|
22
|
+
@reporter = reporter
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reports a newer stable release. fresh: asks RubyGems now instead of
|
|
26
|
+
# trusting a cached answer from the last day.
|
|
27
|
+
def call(fresh: false)
|
|
28
|
+
latest = fresh ? refresh : cached
|
|
29
|
+
return unless latest && latest > Gem::Version.new(VERSION)
|
|
30
|
+
|
|
31
|
+
reporter.info("trmnl_preview #{latest} is available (installed: #{VERSION}).")
|
|
32
|
+
reporter.info(update_guidance)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :cache_path, :reporter
|
|
38
|
+
|
|
39
|
+
def cached
|
|
40
|
+
cache = read_cache
|
|
41
|
+
return version_from(cache['version']) if cache && Time.now.to_i - cache['checked_at'] < CACHE_TTL
|
|
42
|
+
|
|
43
|
+
refresh
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def refresh
|
|
47
|
+
latest = fetch_latest
|
|
48
|
+
write_cache(latest)
|
|
49
|
+
latest
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def read_cache
|
|
53
|
+
data = JSON.parse(cache_path.read)
|
|
54
|
+
data if data.is_a?(Hash) && data['checked_at'].is_a?(Integer)
|
|
55
|
+
rescue SystemCallError, JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write_cache(latest)
|
|
60
|
+
cache_path.dirname.mkpath
|
|
61
|
+
cache_path.write(JSON.generate(checked_at: Time.now.to_i, version: latest&.to_s))
|
|
62
|
+
rescue SystemCallError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch_latest
|
|
67
|
+
response = Faraday.get(ENDPOINT) do |request|
|
|
68
|
+
request.options.open_timeout = TIMEOUT
|
|
69
|
+
request.options.timeout = TIMEOUT
|
|
70
|
+
end
|
|
71
|
+
parse_version(response.body) if response.success?
|
|
72
|
+
rescue Faraday::Error, JSON::ParserError, ArgumentError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_version(body)
|
|
77
|
+
data = JSON.parse(body)
|
|
78
|
+
version_from(data['version']) if data.is_a?(Hash)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def version_from(value)
|
|
82
|
+
return unless value.is_a?(String) && !value.empty? && Gem::Version.correct?(value)
|
|
83
|
+
|
|
84
|
+
version = Gem::Version.new(value)
|
|
85
|
+
version unless version.prerelease?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def update_guidance
|
|
89
|
+
return 'Update with: gem update trmnl_preview' if ENV['BUNDLE_GEMFILE'].to_s.empty?
|
|
90
|
+
|
|
91
|
+
'Update the trmnl_preview constraint in your Gemfile if needed, then run: bundle update trmnl_preview'
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -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