proofsheet 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +115 -0
- data/Rakefile +12 -0
- data/exe/proofsheet +6 -0
- data/lib/proofsheet/browser.rb +113 -0
- data/lib/proofsheet/capturer.rb +109 -0
- data/lib/proofsheet/cli.rb +74 -0
- data/lib/proofsheet/clip.rb +22 -0
- data/lib/proofsheet/credentials.rb +49 -0
- data/lib/proofsheet/manifest.rb +128 -0
- data/lib/proofsheet/version.rb +5 -0
- data/lib/proofsheet.rb +15 -0
- data/sig/proofsheet.rbs +4 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dfb63ef244e4dc5bd439965363d028bb5760f1d76cd4b04b9595278e85990cd5
|
|
4
|
+
data.tar.gz: '0699294e136dc91a4afa0b718b6ddf07626261c9cc6bcb6cdb756e3563679fdb'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '0387c6a7d3fe71a573e937f0df9103c26646218f6f5f1283c7ab78fc72b4b3ec6b26d0361156c6280f9d95355ee21c8321dac89d33f3459ed4435f9f683da64e'
|
|
7
|
+
data.tar.gz: ad13fdcaf9caa12d724e54abaac56b096288a34f76881f15ff49ef008535911c6fbe7a334f653af89b10adee1cc3ef513c2b326aa68bfbc21f7efd3b46d06424
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marc Heiligers
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Proofsheet
|
|
2
|
+
|
|
3
|
+
Proofsheet captures repeatable screenshots from web applications. A YAML manifest describes the browser viewport, authentication, pages, readiness selectors, clicks, safety checks, and element crops. The `proofsheet` CLI runs the manifest in headless Chrome and writes PNG files.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the gem:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install proofsheet
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Proofsheet requires Chrome and libvips on the machine doing the capture.
|
|
14
|
+
|
|
15
|
+
## Manifest
|
|
16
|
+
|
|
17
|
+
Create `proofsheet.yml` in the root of the project whose screenshots you want to capture:
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
host: "https://example.com"
|
|
21
|
+
|
|
22
|
+
expect:
|
|
23
|
+
username: "demo@example.com"
|
|
24
|
+
|
|
25
|
+
credentials:
|
|
26
|
+
username: "env:PROOFSHEET_USERNAME"
|
|
27
|
+
password: "env:PROOFSHEET_PASSWORD"
|
|
28
|
+
|
|
29
|
+
login:
|
|
30
|
+
path: "/sign-in"
|
|
31
|
+
username_field: "email"
|
|
32
|
+
password_field: "password"
|
|
33
|
+
submit: "Sign in"
|
|
34
|
+
expect:
|
|
35
|
+
selector: "header"
|
|
36
|
+
text: "demo@example.com"
|
|
37
|
+
|
|
38
|
+
targets:
|
|
39
|
+
account: 42
|
|
40
|
+
|
|
41
|
+
defaults:
|
|
42
|
+
viewport: [1440, 900]
|
|
43
|
+
output_dir: "docs/screenshots"
|
|
44
|
+
|
|
45
|
+
shots:
|
|
46
|
+
dashboard:
|
|
47
|
+
path: "/accounts/%{account}"
|
|
48
|
+
wait_for: "[data-testid='dashboard']"
|
|
49
|
+
expect:
|
|
50
|
+
selector: ".account-name"
|
|
51
|
+
text: "Demo Account"
|
|
52
|
+
|
|
53
|
+
activity:
|
|
54
|
+
path: "/accounts/%{account}/activity"
|
|
55
|
+
wait_for: "[data-testid='activity-chart']"
|
|
56
|
+
click: "[data-range='7d']"
|
|
57
|
+
clip:
|
|
58
|
+
selector: "[data-testid='activity-chart']"
|
|
59
|
+
padding: 5
|
|
60
|
+
|
|
61
|
+
toolbar:
|
|
62
|
+
path: "/accounts/%{account}"
|
|
63
|
+
clip:
|
|
64
|
+
selector: ".toolbar"
|
|
65
|
+
full_width: true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `login` section is optional; `credentials` is required when `login` is present. The `expect`, `targets`, `wait_for`, `click`, and `clip` sections are optional. The default viewport is `1440×900`, and the default output directory is `screenshots`.
|
|
69
|
+
|
|
70
|
+
Credential sources must be environment variables such as `env:PROOFSHEET_USERNAME` or 1Password secret references such as `op://Vault/Item/username`. Add `op_account` under `credentials` when 1Password needs an explicit account.
|
|
71
|
+
|
|
72
|
+
An `expect.username` value stops capture before Chrome starts if the loaded username is not the intended screenshot account. A login or shot-level `expect` stops capture when the expected text is absent from the selected element.
|
|
73
|
+
|
|
74
|
+
For pages whose final URL is only discoverable in the browser, `path_script` may contain JavaScript that returns a path. Set `resolved_wait_for` to wait for the destination content:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
shots:
|
|
78
|
+
latest-report:
|
|
79
|
+
path: "/reports"
|
|
80
|
+
wait_for: "[data-latest-report]"
|
|
81
|
+
path_script: "document.querySelector('[data-latest-report]').getAttribute('href')"
|
|
82
|
+
resolved_wait_for: "iframe[title='Report']"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## CLI
|
|
86
|
+
|
|
87
|
+
Capture every shot:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
proofsheet capture
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Capture selected shots, use another manifest, or override its host:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
proofsheet capture dashboard activity
|
|
97
|
+
proofsheet capture --config config/docs.yml
|
|
98
|
+
proofsheet capture --host http://localhost:3000
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
List the manifest without starting Chrome:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
proofsheet list
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Relative output paths are resolved from the directory where the command runs.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
Run `bin/setup`, then `bundle exec rake` to run the specs and linter. To install this gem locally, run `bundle exec rake install`.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
Proofsheet is available under the terms of the MIT License.
|
data/Rakefile
ADDED
data/exe/proofsheet
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Proofsheet
|
|
6
|
+
class Browser
|
|
7
|
+
DRIVER = :proofsheet
|
|
8
|
+
LOAD_TIMEOUT = 20
|
|
9
|
+
SETTLE_TIME = 0.5
|
|
10
|
+
|
|
11
|
+
LOADED = <<~JS
|
|
12
|
+
document.readyState === "complete" &&
|
|
13
|
+
(!document.fonts || document.fonts.status === "loaded") &&
|
|
14
|
+
Array.from(document.querySelectorAll("iframe")).every(function (frame) {
|
|
15
|
+
try {
|
|
16
|
+
return !frame.contentDocument || frame.contentDocument.readyState === "complete";
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
JS
|
|
22
|
+
|
|
23
|
+
SCROLL_INTO_VIEW = <<~JS
|
|
24
|
+
arguments[0].style.scrollMargin = arguments[1] + "px";
|
|
25
|
+
arguments[0].scrollIntoView({block: "nearest"});
|
|
26
|
+
JS
|
|
27
|
+
|
|
28
|
+
def initialize(host:, viewport:)
|
|
29
|
+
@host = host
|
|
30
|
+
@width, @height = viewport
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start
|
|
34
|
+
require "capybara"
|
|
35
|
+
require "selenium-webdriver"
|
|
36
|
+
|
|
37
|
+
Capybara.register_driver(DRIVER) { |app| chrome(app) }
|
|
38
|
+
Capybara.run_server = false
|
|
39
|
+
Capybara.app_host = @host
|
|
40
|
+
Capybara.default_max_wait_time = 10
|
|
41
|
+
|
|
42
|
+
@session = Capybara::Session.new(DRIVER)
|
|
43
|
+
@session.visit("/")
|
|
44
|
+
fit_viewport
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def quit
|
|
49
|
+
@session&.quit
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def current_path
|
|
53
|
+
@session.current_path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def visit(path, wait_for: nil)
|
|
57
|
+
@session.visit(path)
|
|
58
|
+
@session.assert_selector(wait_for, visible: :all) if wait_for
|
|
59
|
+
Timeout.timeout(LOAD_TIMEOUT) { sleep 0.1 until @session.evaluate_script(LOADED) }
|
|
60
|
+
sleep SETTLE_TIME
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sign_in(login, username, password)
|
|
64
|
+
@session.visit(login.path)
|
|
65
|
+
@session.fill_in(login.username_field, with: username)
|
|
66
|
+
@session.fill_in(login.password_field, with: password)
|
|
67
|
+
@session.click_button(login.submit)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def shows?(selector, text)
|
|
71
|
+
@session.has_css?(selector, text: text)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def click(selector)
|
|
75
|
+
@session.find(selector).click
|
|
76
|
+
sleep SETTLE_TIME
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def evaluate(script)
|
|
80
|
+
@session.evaluate_script(script)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def screenshot_png
|
|
84
|
+
@session.driver.browser.screenshot_as(:png)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def rect_for(selector, margin: 0)
|
|
88
|
+
node = @session.find(selector, match: :first)
|
|
89
|
+
@session.execute_script(SCROLL_INTO_VIEW, node, margin)
|
|
90
|
+
sleep SETTLE_TIME
|
|
91
|
+
@session.evaluate_script("arguments[0].getBoundingClientRect().toJSON()", node)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def chrome(app)
|
|
97
|
+
options = Selenium::WebDriver::Chrome::Options.new
|
|
98
|
+
["--headless=new", "--hide-scrollbars", "--force-device-scale-factor=1",
|
|
99
|
+
"--window-size=#{@width},#{@height}", "--disable-dev-shm-usage", "--no-sandbox",
|
|
100
|
+
"--disable-gpu"].each { |argument| options.add_argument(argument) }
|
|
101
|
+
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fit_viewport
|
|
105
|
+
inner = @session.evaluate_script("[window.innerWidth, window.innerHeight]")
|
|
106
|
+
return if inner == [@width, @height]
|
|
107
|
+
|
|
108
|
+
window = @session.driver.browser.manage.window
|
|
109
|
+
size = window.size
|
|
110
|
+
window.resize_to(size.width + (@width - inner[0]), size.height + (@height - inner[1]))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Proofsheet
|
|
7
|
+
class Capturer
|
|
8
|
+
def initialize(manifest:, host:, root: Dir.pwd, credentials: nil, browser_class: Browser, out: $stdout)
|
|
9
|
+
@manifest = manifest
|
|
10
|
+
@host = host
|
|
11
|
+
@root = Pathname(root)
|
|
12
|
+
@credentials = credentials
|
|
13
|
+
@browser_class = browser_class
|
|
14
|
+
@out = out
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def capture(names = @manifest.names)
|
|
18
|
+
shots = names.map { |name| @manifest.shot(name) }
|
|
19
|
+
username, password = authentication
|
|
20
|
+
|
|
21
|
+
@browser = @browser_class.new(host: @host, viewport: @manifest.viewport).start
|
|
22
|
+
sign_in(username, password) if @manifest.login
|
|
23
|
+
shots.each { |shot| capture_shot(shot) }
|
|
24
|
+
ensure
|
|
25
|
+
@browser&.quit
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def authentication
|
|
31
|
+
return [nil, nil] unless @manifest.login
|
|
32
|
+
|
|
33
|
+
username = @credentials.username
|
|
34
|
+
expected = @manifest.expected_username
|
|
35
|
+
if expected && username != expected
|
|
36
|
+
raise Error, "credentials hold #{username.inspect} but the manifest expects #{expected.inspect}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
[username, @credentials.password]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sign_in(username, password)
|
|
43
|
+
login = @manifest.login
|
|
44
|
+
@browser.sign_in(login, username, password)
|
|
45
|
+
verify(login.expectation, "sign in") if login.expectation
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def capture_shot(shot)
|
|
49
|
+
@out.puts shot.name
|
|
50
|
+
@browser.visit(@manifest.path_for(shot), wait_for: shot.wait_for)
|
|
51
|
+
verify(shot.expectation, shot.name) if shot.expectation
|
|
52
|
+
resolve_path(shot) if shot.path_script
|
|
53
|
+
@browser.click(shot.click) if shot.click
|
|
54
|
+
write(shot, image_for(shot))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_path(shot)
|
|
58
|
+
path = @browser.evaluate(shot.path_script)
|
|
59
|
+
raise Error, "#{shot.name}: path_script did not return a path" if path.nil? || path.empty?
|
|
60
|
+
|
|
61
|
+
@browser.visit(path, wait_for: shot.resolved_wait_for)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def verify(expectation, context)
|
|
65
|
+
return if @browser.shows?(expectation.selector, expectation.text)
|
|
66
|
+
|
|
67
|
+
raise Error, "#{context}: #{@browser.current_path} does not show #{expectation.text.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def image_for(shot)
|
|
71
|
+
return @browser.screenshot_png unless shot.clip?
|
|
72
|
+
|
|
73
|
+
rect = @browser.rect_for(shot.clip.selector, margin: shot.clip.padding)
|
|
74
|
+
require "vips"
|
|
75
|
+
image = Vips::Image.new_from_buffer(@browser.screenshot_png, "")
|
|
76
|
+
verify_scale(image)
|
|
77
|
+
crop(image, clip_for(shot, rect, image))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def crop(image, clip)
|
|
81
|
+
@out.puts " cropped to #{clip}"
|
|
82
|
+
image.crop(clip.left, clip.top, clip.width, clip.height).write_to_buffer(".png")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def clip_for(shot, rect, image)
|
|
86
|
+
if rect["height"] > image.height
|
|
87
|
+
raise Error, "#{shot.name}: #{shot.clip.selector} is #{rect["height"].ceil}px tall, " \
|
|
88
|
+
"taller than the #{image.height}px viewport"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Clip.from_rect(rect, image_width: image.width, image_height: image.height,
|
|
92
|
+
full_width: shot.clip.full_width, padding: shot.clip.padding)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def verify_scale(image)
|
|
96
|
+
width = @manifest.viewport.first
|
|
97
|
+
return if image.width == width
|
|
98
|
+
|
|
99
|
+
raise Error, "captured #{image.width}px wide but the viewport is #{width}px"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write(shot, png)
|
|
103
|
+
path = @root.join(@manifest.output_dir, shot.filename)
|
|
104
|
+
FileUtils.mkdir_p(path.dirname)
|
|
105
|
+
path.binwrite(png)
|
|
106
|
+
@out.puts " wrote #{path.relative_path_from(@root)}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Proofsheet
|
|
6
|
+
class CLI
|
|
7
|
+
USAGE = "Usage: proofsheet <capture [SHOT ...] | list> [--config PATH] [--host HOST]"
|
|
8
|
+
|
|
9
|
+
def initialize(argv, out: $stdout, err: $stderr, root: Dir.pwd, manifest_loader: Manifest.method(:load),
|
|
10
|
+
capturer_class: Capturer)
|
|
11
|
+
@argv = argv.dup
|
|
12
|
+
@out = out
|
|
13
|
+
@err = err
|
|
14
|
+
@root = root
|
|
15
|
+
@manifest_loader = manifest_loader
|
|
16
|
+
@capturer_class = capturer_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
command = @argv.shift
|
|
21
|
+
return help(0) if %w[help --help -h].include?(command)
|
|
22
|
+
return version if %w[--version -v].include?(command)
|
|
23
|
+
return help(1) unless %w[capture list].include?(command)
|
|
24
|
+
|
|
25
|
+
options = { config: Manifest::DEFAULT_PATH }
|
|
26
|
+
parser(options).parse!(@argv)
|
|
27
|
+
manifest = @manifest_loader.call(options[:config])
|
|
28
|
+
|
|
29
|
+
command == "list" ? list(manifest) : capture(manifest, options)
|
|
30
|
+
0
|
|
31
|
+
rescue OptionParser::ParseError, Error, KeyError => e
|
|
32
|
+
@err.puts "proofsheet: #{e.message}"
|
|
33
|
+
1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parser(options)
|
|
39
|
+
OptionParser.new do |opts|
|
|
40
|
+
opts.on("-c", "--config PATH") { |path| options[:config] = path }
|
|
41
|
+
opts.on("--host HOST") { |host| options[:host] = host }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def list(manifest)
|
|
46
|
+
raise Error, "list does not accept shot names" unless @argv.empty?
|
|
47
|
+
|
|
48
|
+
manifest.shots.each { |shot| @out.puts "#{shot.name.ljust(20)} #{shot.path_template}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def capture(manifest, options)
|
|
52
|
+
host = options[:host] || manifest.host
|
|
53
|
+
raise Error, "host is missing from #{manifest.path}; pass --host HOST" if host.nil? || host.empty?
|
|
54
|
+
|
|
55
|
+
credentials = Credentials.new(manifest.credentials) if manifest.login
|
|
56
|
+
capturer = @capturer_class.new(manifest: manifest, host: host, root: @root,
|
|
57
|
+
credentials: credentials, out: @out)
|
|
58
|
+
names = @argv.empty? ? manifest.names : @argv
|
|
59
|
+
@out.puts "Capturing #{names.size} #{names.size == 1 ? "shot" : "shots"} from #{host}"
|
|
60
|
+
capturer.capture(names)
|
|
61
|
+
@out.puts "Done."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def help(status)
|
|
65
|
+
(status.zero? ? @out : @err).puts USAGE
|
|
66
|
+
status
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def version
|
|
70
|
+
@out.puts Proofsheet::VERSION
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proofsheet
|
|
4
|
+
Clip = Data.define(:left, :top, :width, :height) do
|
|
5
|
+
def self.from_rect(rect, image_width:, image_height:, full_width: false, padding: 0)
|
|
6
|
+
left, width = full_width ? [0, image_width] : axis(rect["x"], rect["width"], image_width, padding)
|
|
7
|
+
top, height = axis(rect["y"], rect["height"], image_height, padding)
|
|
8
|
+
|
|
9
|
+
new(left: left, top: top, width: width, height: height)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.axis(offset, length, limit, padding)
|
|
13
|
+
start = (offset.floor - padding).clamp(0, limit - 1)
|
|
14
|
+
|
|
15
|
+
[start, (length.ceil + (padding * 2)).clamp(1, limit - start)]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
"#{width}×#{height} at #{left},#{top}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Proofsheet
|
|
6
|
+
class Credentials
|
|
7
|
+
def self.op_read(account, reference)
|
|
8
|
+
command = %w[op read]
|
|
9
|
+
command.push("--account", account) unless account.nil?
|
|
10
|
+
out, err, status = Open3.capture3(*command, reference)
|
|
11
|
+
return out if status.success?
|
|
12
|
+
|
|
13
|
+
raise Error, "#{reference}: #{err.strip.empty? ? "op read failed" : err.strip}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(config, env: ENV, reader: Credentials.method(:op_read))
|
|
17
|
+
@config = config
|
|
18
|
+
@env = env
|
|
19
|
+
@reader = reader
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def username
|
|
23
|
+
read(@config.fetch("username"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def password
|
|
27
|
+
read(@config.fetch("password"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def read(source)
|
|
33
|
+
value = case source
|
|
34
|
+
when %r{\Aop://}
|
|
35
|
+
@reader.call(@config["op_account"], source)
|
|
36
|
+
when /\Aenv:(.+)\z/
|
|
37
|
+
@env.fetch(Regexp.last_match(1))
|
|
38
|
+
else
|
|
39
|
+
raise Error, "credential sources must be an env:VARIABLE or op:// reference"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
value.to_s.strip.then { |result| result.empty? ? raise(Error, "#{source} is empty") : result }
|
|
43
|
+
rescue Errno::ENOENT
|
|
44
|
+
raise Error, "the 1Password CLI is not installed"
|
|
45
|
+
rescue KeyError => e
|
|
46
|
+
raise Error, e.message
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Proofsheet
|
|
6
|
+
class Manifest
|
|
7
|
+
ClipSpec = Data.define(:selector, :full_width, :padding)
|
|
8
|
+
Expectation = Data.define(:selector, :text)
|
|
9
|
+
Login = Data.define(:path, :username_field, :password_field, :submit, :expectation)
|
|
10
|
+
|
|
11
|
+
Shot = Data.define(:name, :path_template, :wait_for, :click, :clip, :expectation,
|
|
12
|
+
:path_script, :resolved_wait_for) do
|
|
13
|
+
def clip?
|
|
14
|
+
!clip.nil?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def filename
|
|
18
|
+
"#{name}.png"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
DEFAULT_PATH = "proofsheet.yml"
|
|
23
|
+
|
|
24
|
+
def self.load(path = DEFAULT_PATH)
|
|
25
|
+
new(YAML.safe_load_file(path), path: path)
|
|
26
|
+
rescue Errno::ENOENT
|
|
27
|
+
raise Error, "manifest not found: #{path}"
|
|
28
|
+
rescue Psych::Exception => e
|
|
29
|
+
raise Error, "could not read #{path}: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :path
|
|
33
|
+
|
|
34
|
+
def initialize(data, path: DEFAULT_PATH)
|
|
35
|
+
@data = data
|
|
36
|
+
@path = path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def host
|
|
40
|
+
@data["host"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def expected_username
|
|
44
|
+
@data.dig("expect", "username")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def viewport
|
|
48
|
+
defaults.fetch("viewport", [1440, 900])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def output_dir
|
|
52
|
+
defaults.fetch("output_dir", "screenshots")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def credentials
|
|
56
|
+
@data.fetch("credentials")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def login
|
|
60
|
+
raw = @data["login"]
|
|
61
|
+
return if raw.nil?
|
|
62
|
+
|
|
63
|
+
Login.new(path: raw.fetch("path"), username_field: raw.fetch("username_field"),
|
|
64
|
+
password_field: raw.fetch("password_field"), submit: raw.fetch("submit"),
|
|
65
|
+
expectation: expectation(raw["expect"]))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def names
|
|
69
|
+
shot_data.keys
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def shots
|
|
73
|
+
names.map { |name| shot(name) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def shot(name)
|
|
77
|
+
raw = shot_data[name]
|
|
78
|
+
raise Error, "unknown shot #{name.inspect} — known shots: #{names.join(", ")}" if raw.nil?
|
|
79
|
+
|
|
80
|
+
Shot.new(name: name, path_template: raw.fetch("path"), wait_for: raw["wait_for"],
|
|
81
|
+
click: raw["click"], clip: clip_spec(raw["clip"]), expectation: expectation(raw["expect"]),
|
|
82
|
+
path_script: raw["path_script"], resolved_wait_for: raw["resolved_wait_for"])
|
|
83
|
+
rescue KeyError => e
|
|
84
|
+
raise Error, "#{name}: #{e.message} in #{@path}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def path_for(shot)
|
|
88
|
+
shot.path_template.gsub(/%\{(\w+)\}/) do
|
|
89
|
+
key = Regexp.last_match(1)
|
|
90
|
+
raise Error, "#{shot.name}: no target named #{key.inspect} in #{@path}" unless targets.key?(key)
|
|
91
|
+
|
|
92
|
+
value = targets[key]
|
|
93
|
+
raise Error, "#{shot.name}: targets.#{key} is not set in #{@path}" if value.nil? || value.empty?
|
|
94
|
+
|
|
95
|
+
value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def defaults
|
|
102
|
+
@data.fetch("defaults", {})
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def clip_spec(raw)
|
|
106
|
+
return if raw.nil?
|
|
107
|
+
|
|
108
|
+
ClipSpec.new(selector: raw.fetch("selector"), full_width: raw.fetch("full_width", false),
|
|
109
|
+
padding: raw.fetch("padding", 0))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def expectation(raw)
|
|
113
|
+
return if raw.nil?
|
|
114
|
+
|
|
115
|
+
Expectation.new(selector: raw.fetch("selector"), text: raw.fetch("text"))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def targets
|
|
119
|
+
@targets ||= @data.fetch("targets", {}).transform_values { |value| value&.to_s }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def shot_data
|
|
123
|
+
@data.fetch("shots")
|
|
124
|
+
rescue KeyError
|
|
125
|
+
raise Error, "shots is missing from #{@path}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/proofsheet.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "proofsheet/version"
|
|
4
|
+
|
|
5
|
+
module Proofsheet
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require_relative "proofsheet/browser"
|
|
11
|
+
require_relative "proofsheet/clip"
|
|
12
|
+
require_relative "proofsheet/credentials"
|
|
13
|
+
require_relative "proofsheet/manifest"
|
|
14
|
+
require_relative "proofsheet/capturer"
|
|
15
|
+
require_relative "proofsheet/cli"
|
data/sig/proofsheet.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: proofsheet
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Marc Heiligers
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: capybara
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.40'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.40'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ruby-vips
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.3'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: selenium-webdriver
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '4.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '4.0'
|
|
54
|
+
description: Automate screenshots from web apps for your documentation, landings pages
|
|
55
|
+
and emails
|
|
56
|
+
email:
|
|
57
|
+
- marc@eternal.co.za
|
|
58
|
+
executables:
|
|
59
|
+
- proofsheet
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- LICENSE.txt
|
|
65
|
+
- README.md
|
|
66
|
+
- Rakefile
|
|
67
|
+
- exe/proofsheet
|
|
68
|
+
- lib/proofsheet.rb
|
|
69
|
+
- lib/proofsheet/browser.rb
|
|
70
|
+
- lib/proofsheet/capturer.rb
|
|
71
|
+
- lib/proofsheet/cli.rb
|
|
72
|
+
- lib/proofsheet/clip.rb
|
|
73
|
+
- lib/proofsheet/credentials.rb
|
|
74
|
+
- lib/proofsheet/manifest.rb
|
|
75
|
+
- lib/proofsheet/version.rb
|
|
76
|
+
- sig/proofsheet.rbs
|
|
77
|
+
homepage: https://github.com/FASCINATION-works/proofsheet
|
|
78
|
+
licenses:
|
|
79
|
+
- MIT
|
|
80
|
+
metadata:
|
|
81
|
+
source_code_uri: https://github.com/FASCINATION-works/proofsheet
|
|
82
|
+
changelog_uri: https://github.com/FASCINATION-works/proofsheet/blob/main/CHANGELOG.md
|
|
83
|
+
bug_tracker_uri: https://github.com/FASCINATION-works/proofsheet/issues
|
|
84
|
+
rubygems_mfa_required: 'true'
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 3.2.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 4.0.16
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: Capture repeatable screenshots from web applications
|
|
102
|
+
test_files: []
|