trmnl_preview 0.16.0 → 0.17.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 +10 -0
- data/README.md +37 -0
- data/lib/trmnlp/browser_pool.rb +8 -8
- data/lib/trmnlp/cli.rb +7 -0
- data/lib/trmnlp/commands/test.rb +25 -0
- data/lib/trmnlp/config/project.rb +9 -6
- data/lib/trmnlp/config.rb +2 -2
- data/lib/trmnlp/context.rb +12 -4
- data/lib/trmnlp/errors.rb +1 -0
- data/lib/trmnlp/paths.rb +4 -2
- data/lib/trmnlp/poller.rb +20 -6
- data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
- data/lib/trmnlp/renderer.rb +4 -1
- data/lib/trmnlp/screenshot.rb +23 -9
- data/lib/trmnlp/testing/browser.rb +87 -0
- data/lib/trmnlp/testing/certificate_authority.rb +84 -0
- data/lib/trmnlp/testing/device_models.rb +80 -0
- data/lib/trmnlp/testing/frozen_clock.rb +86 -0
- data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
- data/lib/trmnlp/testing/memory_sampler.rb +44 -0
- data/lib/trmnlp/testing/mock_proxy.rb +124 -0
- data/lib/trmnlp/testing/mock_table.rb +102 -0
- data/lib/trmnlp/testing/node_version.rb +27 -0
- data/lib/trmnlp/testing/plugin.rb +91 -0
- data/lib/trmnlp/testing/report.html.erb +79 -0
- data/lib/trmnlp/testing/report.rb +91 -0
- data/lib/trmnlp/testing/rspec.rb +89 -0
- data/lib/trmnlp/testing/run.rb +150 -0
- data/lib/trmnlp/testing/screen.rb +83 -0
- data/lib/trmnlp/testing/snapshot.rb +55 -0
- data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
- data/lib/trmnlp/transform_pipeline.rb +5 -4
- data/lib/trmnlp/transform_state.rb +4 -1
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp.rb +1 -0
- data/templates/init/.github/workflows/trmnl.yml +33 -2
- data/templates/init/.gitignore +3 -0
- data/templates/init/tests/plugin_spec.rb +32 -0
- data/trmnl_preview.gemspec +5 -0
- data/web/views/render_html.erb +1 -1
- 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
|
+
FileUtils.cp(path, File.join(@dir, name))
|
|
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 'capybara/rspec/matchers'
|
|
5
|
+
require 'rspec/core'
|
|
6
|
+
require 'rspec/expectations'
|
|
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
|
+
FileUtils.cp(screen.png_path, path)
|
|
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 =
|
|
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
|
data/lib/trmnlp/version.rb
CHANGED
data/lib/trmnlp.rb
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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
|
data/templates/init/.gitignore
CHANGED
|
@@ -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
|
data/trmnl_preview.gemspec
CHANGED
|
@@ -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
|
|
data/web/views/render_html.erb
CHANGED
|
@@ -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
|
|