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.
@@ -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
@@ -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 = have_no_overflow.and(have_no_problems).and(have_no_leaked_text)
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 overflow or page errors" do
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
@@ -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'
@@ -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 = @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
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|
@@ -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 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
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.width === 0 && box.height === 0) return false;
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 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;
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, :png_path, :result, :wait_for, :wait_for_timeout
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
- @png_path = browser.capture(self)
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
- 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.19.0'
4
+ VERSION = '0.21.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'