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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-09-09
4
+
5
+ - Initial release
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/proofsheet ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "proofsheet"
5
+
6
+ exit Proofsheet::CLI.new(ARGV).run
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proofsheet
4
+ VERSION = "0.1.0"
5
+ 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"
@@ -0,0 +1,4 @@
1
+ module Proofsheet
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []