trmnl_preview 0.16.0 → 0.17.1

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +37 -0
  4. data/lib/trmnlp/browser_pool.rb +8 -8
  5. data/lib/trmnlp/cli.rb +7 -0
  6. data/lib/trmnlp/commands/test.rb +25 -0
  7. data/lib/trmnlp/config/project.rb +9 -6
  8. data/lib/trmnlp/config.rb +2 -2
  9. data/lib/trmnlp/context.rb +12 -4
  10. data/lib/trmnlp/errors.rb +1 -0
  11. data/lib/trmnlp/paths.rb +4 -2
  12. data/lib/trmnlp/poller.rb +20 -6
  13. data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
  14. data/lib/trmnlp/renderer.rb +4 -1
  15. data/lib/trmnlp/screenshot.rb +23 -9
  16. data/lib/trmnlp/testing/browser.rb +87 -0
  17. data/lib/trmnlp/testing/certificate_authority.rb +84 -0
  18. data/lib/trmnlp/testing/device_models.rb +80 -0
  19. data/lib/trmnlp/testing/frozen_clock.rb +86 -0
  20. data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
  21. data/lib/trmnlp/testing/memory_sampler.rb +44 -0
  22. data/lib/trmnlp/testing/mock_proxy.rb +124 -0
  23. data/lib/trmnlp/testing/mock_table.rb +102 -0
  24. data/lib/trmnlp/testing/node_version.rb +27 -0
  25. data/lib/trmnlp/testing/plugin.rb +91 -0
  26. data/lib/trmnlp/testing/report.html.erb +79 -0
  27. data/lib/trmnlp/testing/report.rb +91 -0
  28. data/lib/trmnlp/testing/rspec.rb +89 -0
  29. data/lib/trmnlp/testing/run.rb +150 -0
  30. data/lib/trmnlp/testing/screen.rb +83 -0
  31. data/lib/trmnlp/testing/snapshot.rb +55 -0
  32. data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
  33. data/lib/trmnlp/transform_pipeline.rb +5 -4
  34. data/lib/trmnlp/transform_state.rb +4 -1
  35. data/lib/trmnlp/version.rb +1 -1
  36. data/lib/trmnlp.rb +1 -0
  37. data/templates/init/.github/workflows/trmnl.yml +33 -2
  38. data/templates/init/.gitignore +3 -0
  39. data/templates/init/tests/plugin_spec.rb +32 -0
  40. data/trmnl_preview.gemspec +5 -0
  41. data/web/views/render_html.erb +1 -1
  42. metadata +62 -1
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'rspec/core'
7
+
8
+ module TRMNLP
9
+ module Testing
10
+ # What `trmnlp test --report DIR` writes: every example with each screen it rendered (the PNG, the boxes
11
+ # of everything drawn, the page's problems) and each transform it ran, as DIR/index.html and
12
+ # DIR/report.json. Under GitHub Actions the counts and failures go to the step summary too.
13
+ class Report
14
+ TEMPLATE = File.expand_path('report.html.erb', __dir__)
15
+ OUTLINES_SCRIPT = <<~JS
16
+ (() => [...document.querySelectorAll('.view *')].slice(0, 3000).map((el) => {
17
+ const box = el.getBoundingClientRect();
18
+ return { tag: el.tagName.toLowerCase(), class: String(el.className.baseVal ?? el.className),
19
+ x: box.x, y: box.y, w: box.width, h: box.height };
20
+ }).filter((box) => box.w > 0 && box.h > 0))()
21
+ JS
22
+
23
+ class << self
24
+ attr_accessor :current
25
+ end
26
+
27
+ def initialize(dir)
28
+ @dir = dir
29
+ @examples = {}
30
+ @images = 0
31
+ end
32
+
33
+ def record_screen(screen)
34
+ entry&.fetch(:screens)&.push(
35
+ label: "#{screen.device.name} · #{screen.view}", width: screen.device.width, height: screen.device.height,
36
+ image: copy_image(screen.png_path), outlines: screen.evaluate(OUTLINES_SCRIPT), problems: screen.problems
37
+ )
38
+ end
39
+
40
+ def record_run(run)
41
+ return unless run.duration_ms
42
+
43
+ entry&.fetch(:runs)&.push(duration_ms: run.duration_ms, max_memory_mb: run.max_memory_mb, error: run.error,
44
+ requests: run.requests.map { it.slice(:method, :url, :status, :via, :aborted) })
45
+ end
46
+
47
+ def example_passed(notification) = finish(notification.example, 'passed')
48
+ def example_pending(notification) = finish(notification.example, 'pending')
49
+ def example_failed(notification) = finish(notification.example, 'failed', notification.example.exception&.message)
50
+
51
+ def close(_notification)
52
+ FileUtils.mkdir_p(@dir)
53
+ File.write(File.join(@dir, 'report.json'), JSON.pretty_generate(examples: examples))
54
+ File.write(File.join(@dir, 'index.html'), ERB.new(File.read(TEMPLATE)).result(binding))
55
+ write_step_summary if ENV['GITHUB_STEP_SUMMARY']
56
+ end
57
+
58
+ private
59
+
60
+ def examples = @examples.values
61
+
62
+ def entry(example = RSpec.current_example)
63
+ return unless example
64
+
65
+ @examples[example.id] ||= { description: example.full_description, location: example.location,
66
+ status: 'running', message: nil, screens: [], runs: [] }
67
+ end
68
+
69
+ def finish(example, status, message = nil) = entry(example).merge!(status:, message:)
70
+
71
+ def copy_image(path)
72
+ name = "images/#{@images += 1}.png"
73
+ FileUtils.mkdir_p(File.join(@dir, 'images'))
74
+ File.binwrite(File.join(@dir, name), File.binread(path)) # not cp, which keeps the screen's private 0600 mode
75
+ name
76
+ end
77
+
78
+ def count(status) = examples.count { it[:status] == status }
79
+
80
+ def write_step_summary
81
+ counts = "#{count('passed')} passed, #{count('failed')} failed, #{count('pending')} pending, " \
82
+ "#{examples.sum { it[:screens].size }} screens"
83
+ lines = ["### trmnlp test\n", "#{counts}\n"]
84
+ examples.select { it[:status] == 'failed' }.each { lines << "- ❌ #{it[:description]} (`#{it[:location]}`)" }
85
+ File.write(ENV.fetch('GITHUB_STEP_SUMMARY'), "#{lines.join("\n")}\n", mode: 'a')
86
+ end
87
+
88
+ def h(text) = ERB::Util.html_escape(text.to_s)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by `trmnlp test`: gives each example `trmnl`, Capybara's matchers and trmnlp's own.
4
+ require 'rspec/core'
5
+ require 'rspec/expectations'
6
+ require 'capybara/rspec/matchers' # after rspec/expectations, or its matchers lose .and and .or
7
+
8
+ require_relative '../../trmnlp'
9
+ require_relative '../browser_pool'
10
+ require_relative '../firefox_driver'
11
+ require_relative 'browser'
12
+ require_relative 'certificate_authority'
13
+ require_relative 'plugin'
14
+ require_relative 'report'
15
+ require_relative 'snapshot'
16
+
17
+ module TRMNLP
18
+ module Testing
19
+ PLUGIN_DIR_ENV_KEY = 'TRMNLP_PLUGIN_DIR'
20
+
21
+ def self.plugin_dir = ENV.fetch(PLUGIN_DIR_ENV_KEY, Dir.pwd)
22
+
23
+ def self.authority
24
+ @authority ||= CertificateAuthority.new.tap { |authority| at_exit { authority.remove } }
25
+ end
26
+
27
+ def self.browser_pool
28
+ @browser_pool ||= BrowserPool.new(driver_factory: FirefoxDriver.method(:build), max_size: 1)
29
+ end
30
+
31
+ module Helpers
32
+ def trmnl = @trmnl ||= Plugin.new(Testing.plugin_dir, browser: trmnl_browser, authority: Testing.authority)
33
+
34
+ def trmnl_browser = @trmnl_browser ||= Browser.new(pool: Testing.browser_pool)
35
+ end
36
+ end
37
+ end
38
+
39
+ RSpec::Matchers.define :have_no_overflow do
40
+ match { |screen| screen.overflowing.empty? }
41
+ failure_message do |screen|
42
+ "expected nothing to overflow on #{screen.inspect}, but: #{screen.overflowing.join(', ')}"
43
+ end
44
+ end
45
+
46
+ # TRMNL gives a transform 5 seconds and 128 MB (help.trmnl.com, Serverless).
47
+ RSpec::Matchers.define :stay_within_serverless_limits do
48
+ match { |run| run.duration_ms.to_i <= 5000 && run.max_memory_mb.to_f <= 128 }
49
+ failure_message do |run|
50
+ "expected the transform to stay within 5000 ms and 128 MB, but it took #{run.duration_ms} ms " \
51
+ "and #{run.max_memory_mb} MB"
52
+ end
53
+ end
54
+
55
+ RSpec::Matchers.define :have_no_problems do
56
+ match { |screen| screen.problems.empty? }
57
+ failure_message { |screen| "expected no problems on #{screen.inspect}, but: #{screen.problems.join('; ')}" }
58
+ end
59
+
60
+ RSpec::Matchers.define :fit_image_size_limit do
61
+ match { |screen| screen.png_bytes.bytesize <= screen.device.image_size_limit.to_i }
62
+ failure_message do |screen|
63
+ "expected the #{screen.device.name} PNG to fit #{screen.device.image_size_limit} bytes, " \
64
+ "but it is #{screen.png_bytes.bytesize}"
65
+ end
66
+ end
67
+
68
+ RSpec::Matchers.define :match_snapshot do |name = nil|
69
+ match do |screen|
70
+ example = RSpec.current_example
71
+ name ||= [example.full_description, screen.device.name, screen.view].join(' ').downcase.gsub(/[^a-z0-9]+/, '-')
72
+ dir = File.join(TRMNLP::Testing.plugin_dir, 'tests', 'snapshots')
73
+ @mismatch = TRMNLP::Testing::Snapshot.new(screen, name:, dir:).mismatch
74
+ @mismatch.nil?
75
+ end
76
+ failure_message { @mismatch }
77
+ end
78
+
79
+ RSpec.configure do |config|
80
+ if (report_dir = ENV.fetch('TRMNLP_REPORT_DIR', nil))
81
+ TRMNLP::Testing::Report.current = TRMNLP::Testing::Report.new(report_dir)
82
+ config.reporter.register_listener(TRMNLP::Testing::Report.current, :example_passed, :example_failed,
83
+ :example_pending, :close)
84
+ end
85
+
86
+ config.include Capybara::RSpecMatchers
87
+ config.include TRMNLP::Testing::Helpers
88
+ config.after { @trmnl_browser&.release }
89
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ require_relative '../context'
8
+ require_relative '../reporter'
9
+ require_relative '../transform_backend/subprocess'
10
+ require_relative '../transform_backend/which'
11
+ require_relative '../transform_client'
12
+ require_relative '../transform_state'
13
+ require_relative 'frozen_clock'
14
+ require_relative 'memory_sampler'
15
+ require_relative 'node_version'
16
+ require_relative 'mock_proxy'
17
+ require_relative 'mock_table'
18
+
19
+ module TRMNLP
20
+ module Testing
21
+ # One transform or render of a plugin, through trmnlp's own pipeline, in a cache directory of its own:
22
+ # the test's custom fields, saved state and previous output go in, its mocks answer every request,
23
+ # and every clock starts at its `now`.
24
+ class Run
25
+ Result = Data.define(:data, :html, :state, :requests, :log, :error, :duration_ms, :max_memory_mb)
26
+
27
+ # The transform client, remembering how long its last run took.
28
+ class TimedClient < SimpleDelegator
29
+ attr_reader :duration_ms
30
+
31
+ def execute(**) = super.tap { @duration_ms = it.duration_ms }
32
+ end
33
+
34
+ # rubocop:disable-next Metrics/ParameterLists -- one keyword per input a test can set
35
+ def initialize(plugin:, authority:, now: nil, custom_fields: {}, variables: {}, state: nil,
36
+ previous_merge_variables: nil, data: nil, mocks: {}, transform: true)
37
+ @plugin = plugin.to_s
38
+ @authority = authority
39
+ @now = now
40
+ @inputs = { custom_fields:, variables:, state:, previous_merge_variables:, data:, transform: }
41
+ @table = MockTable.new(mocks, on_advance_clock: method(:advance_clock))
42
+ @clock_offset = 0
43
+ @reporter = Reporter.new(quiet: true)
44
+ end
45
+
46
+ def transform(device: {})
47
+ call { |context| { data: context.user_data_assembler.call(device:) } }
48
+ end
49
+
50
+ def render(view:, device: {}, screen_classes: nil, theme: nil)
51
+ call do |context|
52
+ html = context.renderer.render_full_page(view, device.merge(screen_classes:, theme:))
53
+ { html:, data: context.renderer.last_data }
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :plugin, :authority, :now, :inputs, :table, :reporter
60
+
61
+ def call
62
+ Dir.mktmpdir('trmnlp-run-') do |cache_dir|
63
+ @clock_file = File.join(cache_dir, 'clock')
64
+ MockProxy.open(table:, authority:) do |proxy|
65
+ context = build_context(cache_dir, proxy)
66
+ parts = FrozenClock.around(now) do
67
+ load_source_data(context)
68
+ yield context
69
+ end
70
+ Result.new(data: nil, html: nil, **parts, **outcome(context))
71
+ end
72
+ end
73
+ end
74
+
75
+ def build_context(cache_dir, proxy)
76
+ transform_client = inputs[:transform] ? transform_client(proxy) : nil
77
+ context = Context.new(plugin, reporter:, cache_dir:, project_overrides:, outbound_request: table,
78
+ transform_client:)
79
+ seed(context.paths)
80
+ context
81
+ end
82
+
83
+ def project_overrides
84
+ overrides = { 'custom_fields' => stringify(inputs[:custom_fields]),
85
+ 'variables' => stringify(inputs[:variables]) }
86
+ inputs[:transform] ? overrides : overrides.merge('transform_runtime' => 'disabled')
87
+ end
88
+
89
+ def transform_client(proxy)
90
+ environment = proxy.environment
91
+ language = transform_language
92
+ NodeVersion.check!(interpreter(language)) if language.to_s == 'node'
93
+ if now && language
94
+ environment = environment.merge(FrozenClock.environment(now, interpreter: interpreter(language),
95
+ clock_file: @clock_file))
96
+ end
97
+ on_spawn = ->(pid) { @memory = MemorySampler.start(pid) }
98
+ backend = TransformBackend::Subprocess.new(environment:, on_spawn:)
99
+ @client = TimedClient.new(TransformClient.new(backend:))
100
+ end
101
+
102
+ def transform_language
103
+ paths = Paths.new(plugin)
104
+ _path, inferred = paths.transform_file
105
+ Config.new(paths).plugin.serverless_language || inferred
106
+ end
107
+
108
+ def interpreter(language)
109
+ commands = TransformBackend::Subprocess::INTERPRETERS.fetch(language.to_s, { cmds: [language.to_s] })[:cmds]
110
+ TransformBackend::Which.locate(TransformBackend::Which.resolve(commands)).to_s
111
+ end
112
+
113
+ def seed(paths)
114
+ write(paths.transform_state, inputs[:state]) if inputs[:state]
115
+ write(paths.transform_output, inputs[:previous_merge_variables]) if inputs[:previous_merge_variables]
116
+ end
117
+
118
+ def load_source_data(context)
119
+ if inputs[:data]
120
+ write(context.paths.user_data, inputs[:data])
121
+ elsif context.config.plugin.polling?
122
+ context.poller.poll_data
123
+ end
124
+ time = now || Time.now
125
+ File.utime(time, time, context.paths.user_data) if context.paths.user_data.exist?
126
+ end
127
+
128
+ # A mock's advance_clock: moves the transform's clock on from the test's now.
129
+ def advance_clock(seconds)
130
+ raise TestingError, 'advance_clock needs now: on the run' unless now
131
+
132
+ @clock_offset += seconds
133
+ FrozenClock.write(@clock_file, now + @clock_offset)
134
+ end
135
+
136
+ def outcome(context)
137
+ table.finish
138
+ { state: TransformState.new(paths: context.paths).read, requests: table.requests, log: reporter.messages,
139
+ error: context.transform_pipeline.error, duration_ms: @client&.duration_ms, max_memory_mb: @memory&.peak_mb }
140
+ end
141
+
142
+ def stringify(value) = JSON.parse(JSON.generate(value || {}))
143
+
144
+ def write(path, value)
145
+ path.dirname.mkpath
146
+ path.write(JSON.generate(value))
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara'
4
+ require 'delegate'
5
+ require 'nokogiri'
6
+
7
+ module TRMNLP
8
+ module Testing
9
+ # A rendered view, as Firefox drew it. Capybara's finders and matchers (have_text, have_css, within...)
10
+ # work on the DOM after the Framework's scripts ran; #box, #evaluate and #overflowing ask the live page.
11
+ class Screen < SimpleDelegator
12
+ Box = Data.define(:left, :top, :right, :bottom, :width, :height)
13
+
14
+ # Every element drawn past the screen's edge or cut off inside a box that hides its overflow.
15
+ OVERFLOW_SCRIPT = <<~JS
16
+ const screen = document.documentElement.getBoundingClientRect();
17
+ const describe = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
18
+ (el.classList.length ? '.' + [...el.classList].join('.') : '');
19
+ return [...document.querySelectorAll('.view *')].filter((el) => {
20
+ const box = el.getBoundingClientRect();
21
+ if (box.width === 0 && box.height === 0) return false;
22
+ const outside = box.right > screen.right + 1 || box.bottom > screen.bottom + 1 || box.left < -1 || box.top < -1;
23
+ 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;
27
+ }).map(describe);
28
+ JS
29
+
30
+ HIDDEN_MARK = 'data-trmnlp-hidden'
31
+ # The DOM with every element Firefox does not draw marked, so Capybara's visible text is what is on screen.
32
+ DRAWN_DOM_SCRIPT = <<~JS.freeze
33
+ const hidden = [...document.querySelectorAll('body *')]
34
+ .filter((el) => !el.checkVisibility({ opacityProperty: true, visibilityProperty: true }));
35
+ hidden.forEach((el) => el.setAttribute('#{HIDDEN_MARK}', ''));
36
+ const copy = document.documentElement.cloneNode(true);
37
+ hidden.forEach((el) => el.removeAttribute('#{HIDDEN_MARK}'));
38
+ copy.querySelectorAll('script, style, noscript, template').forEach((el) => el.remove());
39
+ return copy.outerHTML;
40
+ JS
41
+
42
+ attr_reader :html, :device, :view, :png_path, :result, :wait_for, :wait_for_timeout
43
+
44
+ # rubocop:disable-next Metrics/ParameterLists -- what a render produced and how to wait for it
45
+ def initialize(html:, device:, view:, browser:, result:, wait_for: nil, wait_for_timeout: 5)
46
+ @html = html
47
+ @wait_for = wait_for
48
+ @wait_for_timeout = wait_for_timeout
49
+ @device = device
50
+ @view = view
51
+ @browser = browser
52
+ @result = result
53
+ @png_path = browser.capture(self)
54
+ super(Capybara.string(drawn_dom(browser.on(self) { it.execute_script(DRAWN_DOM_SCRIPT) })))
55
+ end
56
+
57
+ def box(selector)
58
+ rect = evaluate("(() => { const el = document.querySelector(#{selector.to_json}); " \
59
+ 'return el && el.getBoundingClientRect().toJSON(); })()')
60
+ raise TestingError, "No element matches #{selector.inspect} on the #{view} view" unless rect
61
+
62
+ Box.new(**rect.slice('left', 'top', 'right', 'bottom', 'width', 'height').transform_keys(&:to_sym))
63
+ end
64
+
65
+ def evaluate(expression) = @browser.on(self) { it.execute_script("return (#{expression});") }
66
+
67
+ def overflowing = @browser.on(self) { it.execute_script(OVERFLOW_SCRIPT) }
68
+
69
+ # Script errors, unhandled rejections, console.error lines and files that failed to load.
70
+ def problems = evaluate('window.__trmnlpProblems || []')
71
+
72
+ def png_bytes = File.binread(png_path)
73
+
74
+ def drawn_dom(source)
75
+ document = Nokogiri::HTML(source)
76
+ document.css("[#{HIDDEN_MARK}]").each { it['hidden'] = 'hidden' }
77
+ document.to_html
78
+ end
79
+
80
+ def inspect = "#<#{self.class.name} #{device.name} #{view}>"
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'mini_magick'
5
+ require 'rbconfig'
6
+
7
+ module TRMNLP
8
+ module Testing
9
+ # A screen's PNG compared with the one stored for it. Fonts render differently per operating system,
10
+ # so each keeps its own copy; run tests in the trmnlp Docker image for snapshots CI can share.
11
+ class Snapshot
12
+ UPDATE_ENV_KEY = 'TRMNLP_UPDATE_SNAPSHOTS'
13
+
14
+ attr_reader :path
15
+
16
+ def initialize(screen, name:, dir:)
17
+ @screen = screen
18
+ @path = File.join(dir, operating_system, "#{name}.png")
19
+ end
20
+
21
+ # nil when they match (or the stored copy was just written), otherwise why not.
22
+ def mismatch
23
+ return store if ENV[UPDATE_ENV_KEY] || (!File.exist?(path) && !ENV['CI'])
24
+ return "no snapshot at #{path}; run `trmnlp test --update` to create it" unless File.exist?(path)
25
+
26
+ differing = differing_pixels
27
+ "#{differing} pixels differ from #{path}; run `trmnlp test --update` if the change is intended" if differing
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :screen
33
+
34
+ def store
35
+ FileUtils.mkdir_p(File.dirname(path))
36
+ File.binwrite(path, File.binread(screen.png_path)) # not cp, which keeps the screen's private 0600 mode
37
+ nil
38
+ end
39
+
40
+ def differing_pixels
41
+ expected = MiniMagick::Image.open(path)
42
+ actual = MiniMagick::Image.open(screen.png_path)
43
+ return 'all' unless expected.dimensions == actual.dimensions
44
+
45
+ tool = MiniMagick::Tool.new('compare', errors: false)
46
+ tool << '-metric' << 'AE' << path << screen.png_path << 'null:'
47
+ count = nil
48
+ tool.call { |_stdout, stderr, _status| count = stderr.to_f.round }
49
+ count.positive? ? count : nil
50
+ end
51
+
52
+ def operating_system = RbConfig::CONFIG['host_os'][/darwin|linux|mswin|mingw/] || 'other'
53
+ end
54
+ end
55
+ end
@@ -32,6 +32,12 @@ module TRMNLP
32
32
  'php' => { cmds: %w[php], ext: 'php' }
33
33
  }.freeze
34
34
 
35
+ # on_spawn is called with the transform's process id, as soon as it starts.
36
+ def initialize(environment: {}, on_spawn: nil)
37
+ @environment = environment
38
+ @on_spawn = on_spawn
39
+ end
40
+
35
41
  def execute(code:, language:, stdin: '', timeout_seconds: DEFAULT_TIMEOUT)
36
42
  spec = INTERPRETERS[language.to_s]
37
43
  return failure("unsupported language: #{language}") unless spec
@@ -55,7 +61,8 @@ module TRMNLP
55
61
  def run_process(cmd, src_path, stdin, timeout_seconds, output_path)
56
62
  started = monotonic_ms
57
63
 
58
- Open3.popen3(cmd, src_path) do |stdin_io, stdout_io, stderr_io, wait_thr|
64
+ Open3.popen3(@environment, cmd, src_path) do |stdin_io, stdout_io, stderr_io, wait_thr|
65
+ @on_spawn&.call(wait_thr.pid)
59
66
  stdin_io.write(stdin)
60
67
  stdin_io.close
61
68
 
@@ -19,7 +19,8 @@ module TRMNLP
19
19
 
20
20
  attr_reader :error
21
21
 
22
- def initialize(config:, paths:, reporter: Reporter.new)
22
+ def initialize(config:, paths:, reporter: Reporter.new, client: nil)
23
+ @given_client = client
23
24
  @config = config
24
25
  @paths = paths
25
26
  @reporter = reporter
@@ -43,13 +44,13 @@ module TRMNLP
43
44
  {}
44
45
  end
45
46
 
46
- def reset! = @client = nil
47
+ def reset! = @client = @given_client
47
48
 
48
49
  private
49
50
 
50
51
  attr_reader :config, :paths, :reporter, :transform_state
51
52
 
52
- def client = @client ||= TransformClient.from_config(config.project)
53
+ def client = @client ||= @given_client || TransformClient.from_config(config.project)
53
54
 
54
55
  def run(path, inferred_language, data)
55
56
  language = config.plugin.serverless_language || inferred_language
@@ -81,7 +82,7 @@ module TRMNLP
81
82
  transformed = JSON.parse(output)
82
83
  return reject_non_object_output unless transformed.is_a?(Hash)
83
84
 
84
- transform_state.extract!(transformed)
85
+ transform_state.extract!(transformed, fetch_failed: config.plugin.polling? && paths.fetch_failed_marker.exist?)
85
86
  store_output(without_previous_merge_variables(transformed))
86
87
  rescue JSON::ParserError => e
87
88
  @error = "transform produced non-JSON output: #{e.message}"
@@ -21,10 +21,13 @@ module TRMNLP
21
21
  {}
22
22
  end
23
23
 
24
- def extract!(output)
24
+ def extract!(output, fetch_failed: false)
25
25
  return unless output.is_a?(Hash) && output.key?(KEY)
26
26
 
27
27
  state = output.delete(KEY)
28
+ # Like TRMNL: a state built from a failed fetch would replace the last good one.
29
+ return if fetch_failed
30
+
28
31
  rejection = rejection_reason(state)
29
32
  rejection ? reporter.info(reporter.yellow("Ignored #{KEY}: #{rejection}")) : write(state)
30
33
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.16.0'
4
+ VERSION = '0.17.1'
5
5
  end
data/lib/trmnlp.rb CHANGED
@@ -3,6 +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
7
  Oj.mimic_JSON
7
8
  require_relative 'trmnlp/rails_helpers'
8
9
  require_relative 'trmnlp/errors'
@@ -3,6 +3,16 @@ on:
3
3
  pull_request:
4
4
  push:
5
5
  branches: [main]
6
+ workflow_dispatch:
7
+ inputs:
8
+ update_snapshots:
9
+ description: Rewrite the snapshots in tests/snapshots and upload them
10
+ type: boolean
11
+ default: false
12
+
13
+ env:
14
+ # The trmnlp image the tests run in. Pin a release (trmnl/trmnlp:v0.17.0) to keep runs repeatable.
15
+ TRMNLP_IMAGE: trmnl/trmnlp:latest
6
16
 
7
17
  jobs:
8
18
  lint:
@@ -15,9 +25,30 @@ jobs:
15
25
  - run: gem install trmnl_preview
16
26
  - run: trmnlp lint
17
27
 
28
+ test:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v6
32
+ - name: trmnlp test
33
+ run: >
34
+ docker run --rm -e CI=true -e GITHUB_STEP_SUMMARY=/summary.md
35
+ -v "$PWD":/plugin -v "$GITHUB_STEP_SUMMARY":/summary.md "$TRMNLP_IMAGE"
36
+ test --report report ${{ inputs.update_snapshots && '--update' || '' }}
37
+ - if: always()
38
+ uses: actions/upload-artifact@v4
39
+ with:
40
+ name: trmnlp-report
41
+ path: report
42
+ - if: ${{ inputs.update_snapshots }}
43
+ uses: actions/upload-artifact@v4
44
+ with:
45
+ name: snapshots
46
+ path: tests/snapshots
47
+
18
48
  push:
19
- needs: lint
20
- if: github.ref == 'refs/heads/main'
49
+ # Take test out of needs to push to TRMNL even when the tests fail.
50
+ needs: [lint, test]
51
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
21
52
  runs-on: ubuntu-latest
22
53
  steps:
23
54
  - uses: actions/checkout@v6
@@ -1,2 +1,5 @@
1
1
  # trmnlp build output
2
2
  _build/
3
+
4
+ # trmnlp test --report output
5
+ report/
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This plugin's tests, run with `trmnlp test` (add `--report report` for a page of every screen it drew).
4
+ # Docs: https://github.com/usetrmnl/trmnlp#testing-plugins
5
+ RSpec.describe 'My Plugin' do
6
+ # Canned answers for the requests the plugin makes, its polling urls and its transform's own, e.g.
7
+ # { 'https://api.example.com/*' => { json: { temperature: 21 } } }
8
+ let(:mocks) { {} }
9
+
10
+ # The values of the custom fields in src/settings.yml to test with.
11
+ let(:custom_fields) { {} }
12
+
13
+ %w[full half_horizontal half_vertical quadrant].each do |view|
14
+ it "draws the #{view} view without overflow or page errors" do
15
+ screen = trmnl.render(view:, mocks:, custom_fields:)
16
+
17
+ expect(screen).to have_no_overflow
18
+ expect(screen).to have_no_problems
19
+ end
20
+ end
21
+
22
+ it "stays within TRMNL's serverless limits" do
23
+ expect(trmnl.transform(mocks:, custom_fields:)).to stay_within_serverless_limits
24
+ end
25
+
26
+ # A picture of the full view, compared pixel for pixel on every run. Record it once with
27
+ # `trmnlp test --update` (or the workflow's update_snapshots run) and commit tests/snapshots.
28
+ #
29
+ # it 'looks as it did' do
30
+ # expect(trmnl.render(mocks:, custom_fields:)).to match_snapshot
31
+ # end
32
+ end
@@ -57,6 +57,11 @@ Gem::Specification.new do |spec|
57
57
  spec.add_dependency 'mini_magick', '~> 5.3'
58
58
  spec.add_dependency 'selenium-webdriver', '~> 4.44'
59
59
 
60
+ # Plugin tests (`trmnlp test`)
61
+ spec.add_dependency 'capybara', '~> 3.40'
62
+ spec.add_dependency 'rspec-core', '~> 3.13'
63
+ spec.add_dependency 'rspec-expectations', '~> 3.13'
64
+
60
65
  # OAuth2 (local plugin preview auth flow)
61
66
  spec.add_dependency 'oauth2', '~> 2.0'
62
67
 
@@ -38,7 +38,7 @@
38
38
  </script>
39
39
  <link rel="stylesheet" href="<%= @framework.css_url %>" />
40
40
  <% if @theme_css_url %><link rel="stylesheet" href="<%= @theme_css_url %>" /><% end %>
41
- <script src="<%= @framework.js_url %>"></script>
41
+ <script type="module" src="<%= @framework.js_url %>"></script>
42
42
  <meta name="trmnl-framework-version" content="<%= @framework.number %>" />
43
43
  <meta name="trmnl-framework-pinned" content="<%= @framework.pinned? %>" />
44
44