rustwright 0.2.0-x86_64-linux

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: 0d7e8b181768892ede9b726e55e50f2ef9fdeb37750899dd7945e6048ce6ad69
4
+ data.tar.gz: 901f1a118117a7afa26981ba613a7fdfb6efd465253b26aa46d75a4827c7463f
5
+ SHA512:
6
+ metadata.gz: 182385168ed5c9db42334a95ec5bef817dc7be4ce9598bb57b0004b11d1002fc9fae117ca0c3e8e7dc021bce32cefe9543d3acc57fb84624b12a9a67a61e9c1b
7
+ data.tar.gz: d19f863af9fa37980be323e76aafce95d309453c6fb17305f23d84a0d2bbe4aecad8f7f1ba21368237bb108bd9fa4020a4962a0ca1079dc0c257cf60fe7b04fa
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Rustwright Ruby binding
2
+
3
+ This Phase 0 binding exposes the complete Rustwright alpha API through Ruby's
4
+ stdlib `Fiddle`; it has no native gem dependency and supports Ruby 2.6 or newer.
5
+ JSON is handled by the stdlib `json` package.
6
+
7
+ ## Install (RubyGems)
8
+
9
+ The platform gems are not published yet. Once available, install the gem with:
10
+
11
+ ```sh
12
+ gem install rustwright
13
+ ```
14
+
15
+ Published gems will include the native C API library and require no separate
16
+ Rust build. Supported gem platforms are `arm64-darwin`, `x86_64-darwin`,
17
+ `aarch64-linux`, and `x86_64-linux`. Windows is not currently shipped because
18
+ the Rust target, Ruby runtime, and RubyGems platform combinations have not yet
19
+ been validated together.
20
+
21
+ The bundled library lives at
22
+ `lib/rustwright/native/<platform>/librustwright_capi.<dylib|so>`. Runtime
23
+ resolution uses an explicit `library_path` first, then
24
+ `RUSTWRIGHT_CAPI_LIB`, then the bundled native, and finally the source-checkout
25
+ fallback described below.
26
+
27
+ ## Run from source
28
+
29
+ From the repository root, run the browser smoke test:
30
+
31
+ ```sh
32
+ ruby ruby/smoke.rb --lib target/release/librustwright_capi.dylib
33
+ ```
34
+
35
+ Run the five-case benchmark subset and write its result document:
36
+
37
+ ```sh
38
+ ruby ruby/runner.rb --manifest bindings/cases/smoke.json --lib target/release/librustwright_capi.dylib --out /tmp/ruby-results.json
39
+ ```
40
+
41
+ On Linux, pass the corresponding `.so`. When run from the repository root,
42
+ direct API use defaults to `target/release/librustwright_capi.dylib` (or `.so`)
43
+ and can select another path with `RUSTWRIGHT_CAPI_LIB`; the smoke command uses
44
+ that default when `--lib` is omitted. The runner requires `--lib`, and an
45
+ explicit path always loads that exact library. The runner also supports `--cases
46
+ id1,id2`, preserves manifest order, exits 0 only when all selected cases pass,
47
+ and exits 1 for case failures (invocation/manifest errors use exit 2).
48
+
49
+ Build a platform gem from an already-built native library with:
50
+
51
+ ```sh
52
+ ruby ruby/package.rb arm64-darwin target/release/librustwright_capi.dylib
53
+ ```
54
+
55
+ The package is written to `ruby/pkg/`; its temporary staged native is removed
56
+ after the build. Replace the platform and extension with one of the supported
57
+ combinations listed above.
58
+
59
+ Run the dependency-free contract tests with:
60
+
61
+ ```sh
62
+ ruby ruby/test/contract_test.rb
63
+ ```
64
+
65
+ ## API
66
+
67
+ ```ruby
68
+ require_relative 'ruby/lib/rustwright'
69
+
70
+ browser = Rustwright.chromium.launch(headless: true)
71
+ begin
72
+ page = browser.new_page
73
+ begin
74
+ page.goto('data:text/html,<title>Hello</title>')
75
+ puts page.title
76
+ bytes = page.screenshot(full_page: true, type: :png)
77
+ ensure
78
+ page.close
79
+ end
80
+ ensure
81
+ browser.close
82
+ end
83
+ ```
84
+
85
+ `Rustwright.chromium.executable_path` discovers Chromium. Browser methods are
86
+ `new_page`, `close`, and `ws_endpoint`. Page methods are `goto`, `click`,
87
+ `fill`, `title`, `text_content`, `evaluate`, `screenshot`, and `close`.
88
+ Timeouts are milliseconds; omitting one sends IEEE-754 NaN to mean “no explicit
89
+ timeout.” Launch accepts snake_case, camelCase, string, or symbol option keys
90
+ and normalizes them to the core wire shape. Screenshot accepts Ruby snake_case
91
+ or Node camelCase keys and sends the Node screenshot shape.
92
+
93
+ Native handles are serialized per browser, explicitly closed, and then freed
94
+ exactly once. Returned strings and screenshot buffers are copied into Ruby
95
+ memory before their matching Rustwright free function is called. Call `close`
96
+ in `ensure` blocks as shown; `close` is idempotent.
97
+
98
+ ## Evaluate values
99
+
100
+ `evaluate` JSON-encodes an explicitly supplied argument (including `nil`) and
101
+ recursively decodes the core CDP wire. Wrapped arrays/objects and repeated or
102
+ cyclic references become Ruby arrays/hashes. Non-finite numbers and BigInts
103
+ become `Float`/`Integer`, dates become `Time`, regexes become `Regexp`, URLs
104
+ become `URI`, and JavaScript errors become `Rustwright::JavaScriptError`.
105
+ JavaScript `undefined`, symbols, and functions have no lossless Ruby value and
106
+ fall back to `nil`.
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require_relative '../rustwright'
6
+
7
+ module Rustwright
8
+ class ManifestError < Error; end
9
+
10
+ module Manifest
11
+ module_function
12
+
13
+ OPERATIONS = %w[
14
+ goto click fill title textContent evaluate screenshot
15
+ assertTitle assertText assertEval
16
+ ].freeze
17
+
18
+ CAPTURE_OPERATIONS = %w[title textContent evaluate screenshot].freeze
19
+
20
+ def load(path)
21
+ parsed = JSON.parse(File.read(path, encoding: Encoding::UTF_8))
22
+ validate!(parsed)
23
+ parsed
24
+ rescue Errno::ENOENT, Errno::EACCES => e
25
+ raise ManifestError, "cannot read manifest #{path}: #{e.message}"
26
+ rescue JSON::ParserError => e
27
+ raise ManifestError, "invalid manifest JSON: #{e.message}"
28
+ end
29
+
30
+ def validate!(manifest)
31
+ object!(manifest, '$')
32
+ keys!(manifest, %w[version cases], %w[version cases], '$')
33
+ raise ManifestError, '$.version must be 1' unless manifest['version'] == 1
34
+
35
+ cases = manifest['cases']
36
+ array!(cases, '$.cases')
37
+ raise ManifestError, '$.cases must contain at least one case' if cases.empty?
38
+
39
+ ids = {}
40
+ cases.each_with_index do |benchmark_case, index|
41
+ path = "$.cases[#{index}]"
42
+ validate_case!(benchmark_case, path)
43
+ id = benchmark_case['id']
44
+ raise ManifestError, "duplicate case id #{id.inspect}" if ids.key?(id)
45
+
46
+ ids[id] = true
47
+ end
48
+ manifest
49
+ end
50
+
51
+ def validate_case!(benchmark_case, path)
52
+ object!(benchmark_case, path)
53
+ keys!(benchmark_case, %w[id description html url repeat steps], %w[id steps], path)
54
+ non_empty_string!(benchmark_case['id'], "#{path}.id")
55
+ string!(benchmark_case['description'], "#{path}.description") if benchmark_case.key?('description')
56
+ string!(benchmark_case['html'], "#{path}.html") if benchmark_case.key?('html')
57
+ if benchmark_case.key?('url')
58
+ string!(benchmark_case['url'], "#{path}.url")
59
+ begin
60
+ URI.parse(benchmark_case['url'])
61
+ rescue URI::InvalidURIError => e
62
+ raise ManifestError, "#{path}.url is not a URI reference: #{e.message}"
63
+ end
64
+ end
65
+ repeat = benchmark_case.fetch('repeat', 1)
66
+ unless repeat.is_a?(Integer) && repeat.between?(1, 1_000)
67
+ raise ManifestError, "#{path}.repeat must be an integer between 1 and 1000"
68
+ end
69
+
70
+ steps = benchmark_case['steps']
71
+ array!(steps, "#{path}.steps")
72
+ raise ManifestError, "#{path}.steps must contain at least one step" if steps.empty?
73
+
74
+ steps.each_with_index do |step, index|
75
+ step_path = "#{path}.steps[#{index}]"
76
+ validate_step!(step, step_path, benchmark_case)
77
+ end
78
+
79
+ first_goto = steps.index { |step| step['op'] == 'goto' }
80
+ if repeat > 1 && first_goto.nil?
81
+ raise ManifestError, "case #{benchmark_case['id'].inspect} has repeat #{repeat} but no goto step"
82
+ end
83
+
84
+ capture_blocks = if repeat == 1
85
+ [steps]
86
+ else
87
+ [steps[0..first_goto], steps[(first_goto + 1)..-1]]
88
+ end
89
+ capture_blocks.each do |block|
90
+ validate_unique_captures!(block, benchmark_case['id'])
91
+ end
92
+ end
93
+ private_class_method :validate_case!
94
+
95
+ def validate_unique_captures!(steps, case_id)
96
+ captures = {}
97
+ steps.each do |step|
98
+ next unless CAPTURE_OPERATIONS.include?(step['op'])
99
+
100
+ capture = step['capture']
101
+ if captures.key?(capture)
102
+ raise ManifestError, "duplicate capture #{capture.inspect} in case #{case_id.inspect}"
103
+ end
104
+ captures[capture] = true
105
+ end
106
+ end
107
+ private_class_method :validate_unique_captures!
108
+
109
+ def validate_step!(step, path, benchmark_case)
110
+ object!(step, path)
111
+ non_empty_string!(step['op'], "#{path}.op") if step.key?('op')
112
+ operation = step['op']
113
+ raise ManifestError, "#{path}.op is required" if operation.nil?
114
+ raise ManifestError, "#{path}.op has unknown operation #{operation.inspect}" unless OPERATIONS.include?(operation)
115
+
116
+ case operation
117
+ when 'goto'
118
+ keys!(step, %w[op url useCaseHtml waitUntil], %w[op], path)
119
+ sources = [step.key?('url'), step.key?('useCaseHtml')].count(true)
120
+ raise ManifestError, "#{path} must contain exactly one of url or useCaseHtml" unless sources == 1
121
+ non_empty_string!(step['url'], "#{path}.url") if step.key?('url')
122
+ if step.key?('useCaseHtml')
123
+ raise ManifestError, "#{path}.useCaseHtml must be true" unless step['useCaseHtml'] == true
124
+ unless benchmark_case.key?('html')
125
+ raise ManifestError, "#{path}.useCaseHtml requires case html"
126
+ end
127
+ end
128
+ if step.key?('waitUntil')
129
+ allowed = %w[load domcontentloaded networkidle commit]
130
+ unless allowed.include?(step['waitUntil'])
131
+ raise ManifestError, "#{path}.waitUntil must be one of #{allowed.join(', ')}"
132
+ end
133
+ end
134
+ when 'click'
135
+ keys!(step, %w[op selector], %w[op selector], path)
136
+ non_empty_string!(step['selector'], "#{path}.selector")
137
+ when 'fill'
138
+ keys!(step, %w[op selector value], %w[op selector value], path)
139
+ non_empty_string!(step['selector'], "#{path}.selector")
140
+ string!(step['value'], "#{path}.value")
141
+ when 'title'
142
+ capture_step!(step, path, %w[op capture])
143
+ when 'textContent'
144
+ capture_step!(step, path, %w[op selector capture])
145
+ non_empty_string!(step['selector'], "#{path}.selector")
146
+ when 'evaluate'
147
+ capture_step!(step, path, %w[op expression arg capture], %w[op expression capture])
148
+ non_empty_string!(step['expression'], "#{path}.expression")
149
+ when 'screenshot'
150
+ capture_step!(step, path, %w[op capture])
151
+ when 'assertTitle'
152
+ assertion_predicate!(step, path, %w[op equals contains])
153
+ when 'assertText'
154
+ assertion_predicate!(step, path, %w[op selector equals contains], %w[op selector])
155
+ non_empty_string!(step['selector'], "#{path}.selector")
156
+ when 'assertEval'
157
+ keys!(step, %w[op expression equals], %w[op expression equals], path)
158
+ non_empty_string!(step['expression'], "#{path}.expression")
159
+ end
160
+ end
161
+ private_class_method :validate_step!
162
+
163
+ def capture_step!(step, path, allowed, required = allowed)
164
+ keys!(step, allowed, required, path)
165
+ non_empty_string!(step['capture'], "#{path}.capture")
166
+ end
167
+ private_class_method :capture_step!
168
+
169
+ def assertion_predicate!(step, path, allowed, required = %w[op])
170
+ keys!(step, allowed, required, path)
171
+ predicates = [step.key?('equals'), step.key?('contains')].count(true)
172
+ raise ManifestError, "#{path} must contain exactly one of equals or contains" unless predicates == 1
173
+
174
+ string!(step['equals'], "#{path}.equals") if step.key?('equals')
175
+ string!(step['contains'], "#{path}.contains") if step.key?('contains')
176
+ end
177
+ private_class_method :assertion_predicate!
178
+
179
+ def keys!(value, allowed, required, path)
180
+ unknown = value.keys - allowed
181
+ missing = required - value.keys
182
+ raise ManifestError, "#{path} has unknown properties: #{unknown.join(', ')}" unless unknown.empty?
183
+ raise ManifestError, "#{path} is missing required properties: #{missing.join(', ')}" unless missing.empty?
184
+ end
185
+ private_class_method :keys!
186
+
187
+ def object!(value, path)
188
+ raise ManifestError, "#{path} must be an object" unless value.is_a?(Hash)
189
+ end
190
+ private_class_method :object!
191
+
192
+ def array!(value, path)
193
+ raise ManifestError, "#{path} must be an array" unless value.is_a?(Array)
194
+ end
195
+ private_class_method :array!
196
+
197
+ def string!(value, path)
198
+ raise ManifestError, "#{path} must be a string" unless value.is_a?(String)
199
+ end
200
+ private_class_method :string!
201
+
202
+ def non_empty_string!(value, path)
203
+ string!(value, path)
204
+ raise ManifestError, "#{path} must not be empty" if value.empty?
205
+ end
206
+ private_class_method :non_empty_string!
207
+ end
208
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle'
4
+
5
+ module Rustwright
6
+ # Thin, ownership-aware wrapper around the complete rustwright C ABI.
7
+ class Native
8
+ VOIDP = Fiddle::TYPE_VOIDP
9
+ INT = Fiddle::TYPE_INT
10
+ DOUBLE = Fiddle::TYPE_DOUBLE
11
+ SIZE_T = Fiddle::TYPE_SIZE_T
12
+ VOID = Fiddle::TYPE_VOID
13
+
14
+ SIGNATURES = {
15
+ rw_last_error: [[], VOIDP],
16
+ rw_string_free: [[VOIDP], VOID],
17
+ rw_bytes_free: [[VOIDP, SIZE_T], VOID],
18
+ rw_chromium_executable_path: [[VOIDP], INT],
19
+ rw_chromium_launch: [[VOIDP, VOIDP], INT],
20
+ rw_browser_new_page: [[VOIDP, VOIDP], INT],
21
+ rw_browser_close: [[VOIDP], INT],
22
+ rw_browser_ws_endpoint: [[VOIDP], VOIDP],
23
+ rw_browser_free: [[VOIDP], VOID],
24
+ rw_page_target_id: [[VOIDP], VOIDP],
25
+ rw_page_goto: [[VOIDP, VOIDP, VOIDP, DOUBLE, VOIDP, VOIDP], INT],
26
+ rw_page_click: [[VOIDP, VOIDP, DOUBLE], INT],
27
+ rw_page_fill: [[VOIDP, VOIDP, VOIDP, DOUBLE], INT],
28
+ rw_page_title: [[VOIDP, DOUBLE, VOIDP], INT],
29
+ rw_page_text_content: [[VOIDP, VOIDP, DOUBLE, VOIDP], INT],
30
+ rw_page_evaluate: [[VOIDP, VOIDP, VOIDP, DOUBLE, VOIDP], INT],
31
+ rw_page_screenshot: [[VOIDP, VOIDP, VOIDP, VOIDP], INT],
32
+ rw_page_close: [[VOIDP, DOUBLE, INT], INT],
33
+ rw_page_free: [[VOIDP], VOID]
34
+ }.freeze
35
+
36
+ attr_reader :path
37
+
38
+ def initialize(path)
39
+ @path = File.expand_path(path)
40
+ @handle = Fiddle.dlopen(@path)
41
+ @functions = {}
42
+ SIGNATURES.each do |name, (arguments, result)|
43
+ @functions[name] = Fiddle::Function.new(@handle[name.to_s], arguments, result)
44
+ end
45
+ rescue Fiddle::DLError => e
46
+ raise Rustwright::Error, "cannot load Rustwright library #{@path}: #{e.message}"
47
+ end
48
+
49
+ def call(name, *arguments)
50
+ if arguments.any? { |argument| argument.is_a?(String) && argument.include?("\0") }
51
+ raise Rustwright::Error, 'strings passed to the C ABI cannot contain NUL'
52
+ end
53
+
54
+ @functions.fetch(name).call(*arguments)
55
+ end
56
+
57
+ # This must be invoked before any other ABI call after a failing status.
58
+ def check_status!(status, operation)
59
+ return if status.zero?
60
+
61
+ raise Rustwright::Error, last_error_immediately(operation, status)
62
+ end
63
+
64
+ # This must be invoked immediately after a direct pointer return is NULL.
65
+ def raise_null_error!(operation)
66
+ raise Rustwright::Error, last_error_immediately(operation, nil)
67
+ end
68
+
69
+ def pointer_slot
70
+ slot = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
71
+ slot[0, Fiddle::SIZEOF_VOIDP] = [0].pack('J')
72
+ slot
73
+ end
74
+
75
+ def size_slot
76
+ slot = Fiddle::Pointer.malloc(Fiddle::SIZEOF_SIZE_T)
77
+ slot[0, Fiddle::SIZEOF_SIZE_T] = [0].pack(size_t_pack)
78
+ slot
79
+ end
80
+
81
+ def pointer_address(slot)
82
+ slot[0, Fiddle::SIZEOF_VOIDP].unpack('J').first
83
+ end
84
+
85
+ def size_value(slot)
86
+ slot[0, Fiddle::SIZEOF_SIZE_T].unpack(size_t_pack).first
87
+ end
88
+
89
+ def null?(pointer)
90
+ pointer.nil? || pointer.to_i.zero?
91
+ end
92
+
93
+ def copy_owned_string(pointer, nullable: false)
94
+ if null?(pointer)
95
+ return nil if nullable
96
+
97
+ raise Rustwright::Error, 'Rustwright returned an unexpected NULL string'
98
+ end
99
+
100
+ address = pointer.to_i
101
+ begin
102
+ Fiddle::Pointer.new(address).to_s.dup.force_encoding(Encoding::UTF_8)
103
+ ensure
104
+ call(:rw_string_free, address)
105
+ end
106
+ end
107
+
108
+ def copy_owned_bytes(pointer, length)
109
+ address = pointer.to_i
110
+ if address.zero?
111
+ # The ABI transfers even the empty NULL/0 pair; release that exact pair.
112
+ call(:rw_bytes_free, address, length)
113
+ return ''.b if length.zero?
114
+
115
+ raise Rustwright::Error, "Rustwright returned NULL bytes with length #{length}"
116
+ end
117
+
118
+ begin
119
+ Fiddle::Pointer.new(address).to_str(length).dup.force_encoding(Encoding::BINARY)
120
+ ensure
121
+ call(:rw_bytes_free, address, length)
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def last_error_immediately(operation, status)
128
+ borrowed = call(:rw_last_error)
129
+ message = if null?(borrowed)
130
+ 'no native error message was provided'
131
+ else
132
+ Fiddle::Pointer.new(borrowed.to_i).to_s.dup.force_encoding(Encoding::UTF_8)
133
+ end
134
+ suffix = status.nil? ? '' : " (status #{status})"
135
+ "#{operation} failed#{suffix}: #{message}"
136
+ end
137
+
138
+ def size_t_pack
139
+ case Fiddle::SIZEOF_SIZE_T
140
+ when 8 then 'Q'
141
+ when 4 then 'L'
142
+ else
143
+ raise Rustwright::Error, "unsupported size_t width: #{Fiddle::SIZEOF_SIZE_T}"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../rustwright'
5
+ require_relative 'manifest'
6
+
7
+ module Rustwright
8
+ class AssertionError < Error; end
9
+
10
+ class Runner
11
+ def initialize(manifest:, library_path:, case_ids: nil)
12
+ @manifest = manifest
13
+ @library_path = library_path
14
+ @case_ids = case_ids
15
+ end
16
+
17
+ def run
18
+ cases = selected_cases
19
+ browser = Rustwright.chromium(library_path: @library_path).launch(headless: true)
20
+ results = []
21
+ close_error = nil
22
+
23
+ begin
24
+ cases.each { |benchmark_case| results << run_case(browser, benchmark_case) }
25
+ ensure
26
+ begin
27
+ browser.close
28
+ rescue StandardError => e
29
+ close_error = e
30
+ end
31
+ end
32
+
33
+ if close_error
34
+ result = results.last
35
+ if result
36
+ result['ok'] = false
37
+ result['error'] ||= "browser close: #{close_error.message}"
38
+ else
39
+ raise close_error
40
+ end
41
+ end
42
+
43
+ { 'lang' => 'ruby', 'results' => results }
44
+ end
45
+
46
+ private
47
+
48
+ def selected_cases
49
+ cases = @manifest['cases']
50
+ return cases if @case_ids.nil?
51
+
52
+ available = cases.each_with_object({}) { |item, ids| ids[item['id']] = true }
53
+ unknown = @case_ids.reject { |id| available.key?(id) }
54
+ unless unknown.empty?
55
+ raise ManifestError, "unknown requested case id(s): #{unknown.join(', ')}"
56
+ end
57
+
58
+ selected = @case_ids.each_with_object({}) { |id, ids| ids[id] = true }
59
+ cases.select { |item| selected.key?(item['id']) }
60
+ end
61
+
62
+ def run_case(browser, benchmark_case)
63
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ captures = {}
65
+ error = nil
66
+ page = nil
67
+
68
+ begin
69
+ page = browser.new_page
70
+ execute_steps(page, benchmark_case, captures)
71
+ rescue StandardError => e
72
+ error = page ? e.message : "page creation: #{e.message}"
73
+ ensure
74
+ if page
75
+ begin
76
+ page.close
77
+ rescue StandardError => e
78
+ error ||= "page close: #{e.message}"
79
+ end
80
+ end
81
+ end
82
+
83
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
84
+ result = {
85
+ 'id' => benchmark_case['id'],
86
+ 'ok' => error.nil?,
87
+ 'captures' => captures,
88
+ 'ms' => elapsed_ms.round(3)
89
+ }
90
+ result['error'] = error unless error.nil?
91
+ result
92
+ end
93
+
94
+ def execute_steps(page, benchmark_case, captures)
95
+ steps = benchmark_case['steps']
96
+ repeat = benchmark_case.fetch('repeat', 1)
97
+ return execute_step_block(page, benchmark_case, steps, 0, captures) if repeat == 1
98
+
99
+ first_goto = steps.index { |step| step['op'] == 'goto' }
100
+ unless first_goto
101
+ raise ManifestError, "case #{benchmark_case['id'].inspect} has repeat #{repeat} but no goto step"
102
+ end
103
+
104
+ execute_step_block(page, benchmark_case, steps[0..first_goto], 0, captures)
105
+ 1.upto(repeat) do |iteration|
106
+ iteration_captures = {}
107
+ begin
108
+ execute_step_block(
109
+ page,
110
+ benchmark_case,
111
+ steps[(first_goto + 1)..-1],
112
+ first_goto + 1,
113
+ iteration_captures
114
+ )
115
+ rescue StandardError => e
116
+ captures.merge!(iteration_captures)
117
+ raise Error, "iteration #{iteration}: #{e.message}"
118
+ end
119
+ captures.merge!(iteration_captures)
120
+ end
121
+ end
122
+
123
+ def execute_step_block(page, benchmark_case, steps, index_offset, captures)
124
+ steps.each_with_index do |step, index|
125
+ execute_step(page, benchmark_case, step, captures)
126
+ rescue StandardError => e
127
+ raise Error, "step #{index_offset + index + 1}: #{e.message}"
128
+ end
129
+ end
130
+
131
+ def execute_step(page, benchmark_case, step, captures)
132
+ case step['op']
133
+ when 'goto'
134
+ url = step['useCaseHtml'] ? Rustwright.inline_html_url(benchmark_case['html']) : step['url']
135
+ page.goto(url, wait_until: step['waitUntil'])
136
+ when 'click'
137
+ page.click(step['selector'])
138
+ when 'fill'
139
+ page.fill(step['selector'], step['value'])
140
+ when 'title'
141
+ insert_capture(captures, step['capture'], page.title)
142
+ when 'textContent'
143
+ insert_capture(captures, step['capture'], page.text_content(step['selector']))
144
+ when 'evaluate'
145
+ value = if step.key?('arg')
146
+ page.evaluate(step['expression'], step['arg'])
147
+ else
148
+ page.evaluate(step['expression'])
149
+ end
150
+ insert_capture(captures, step['capture'], value)
151
+ when 'screenshot'
152
+ insert_capture(captures, step['capture'], page.screenshot.bytesize)
153
+ when 'assertTitle'
154
+ assert_string(page.title, step, 'title')
155
+ when 'assertText'
156
+ assert_string(page.text_content(step['selector']), step, "textContent for #{step['selector'].inspect}")
157
+ when 'assertEval'
158
+ actual = page.evaluate(step['expression'])
159
+ return if actual == step['equals']
160
+
161
+ raise AssertionError, "expected evaluation #{step['equals'].inspect}, got #{actual.inspect}"
162
+ else
163
+ # Validation makes this unreachable, but retain a defensive boundary.
164
+ raise ManifestError, "unknown operation #{step['op'].inspect}"
165
+ end
166
+ end
167
+
168
+ def insert_capture(captures, name, value)
169
+ raise ManifestError, "duplicate capture name #{name.inspect}" if captures.key?(name)
170
+
171
+ captures[name] = value
172
+ end
173
+
174
+ def assert_string(actual, step, label)
175
+ raise AssertionError, "expected #{label} to be a string, got null" if actual.nil?
176
+
177
+ if step.key?('equals')
178
+ return if actual == step['equals']
179
+
180
+ raise AssertionError, "expected #{label} #{step['equals'].inspect}, got #{actual.inspect}"
181
+ end
182
+
183
+ return if actual.include?(step['contains'])
184
+
185
+ raise AssertionError, "expected #{label} to contain #{step['contains'].inspect}, got #{actual.inspect}"
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'uri'
5
+
6
+ module Rustwright
7
+ class JavaScriptError < StandardError
8
+ attr_reader :javascript_name, :javascript_stack
9
+
10
+ def initialize(value)
11
+ @javascript_name = value['name'] || 'Error'
12
+ @javascript_stack = value['stack'] || ''
13
+ super(value['message'] || '')
14
+ end
15
+ end
16
+
17
+ module Wire
18
+ module_function
19
+
20
+ MARKER = '__rustwright_cdp_unserializable_value__'
21
+
22
+ def decode(value)
23
+ decode_value(value, {})
24
+ end
25
+
26
+ def decode_value(value, references)
27
+ case value
28
+ when Array
29
+ value.map { |item| decode_value(item, references) }
30
+ when Hash
31
+ decode_hash(value, references)
32
+ else
33
+ value
34
+ end
35
+ end
36
+ private_class_method :decode_value
37
+
38
+ def decode_hash(value, references)
39
+ if value.key?('__rustwright_cdp_array__')
40
+ target = []
41
+ references[value['__rustwright_cdp_array__']] = target
42
+ target.concat(Array(value['items']).map { |item| decode_value(item, references) })
43
+ return target
44
+ end
45
+
46
+ if value.key?('__rustwright_cdp_object__')
47
+ target = {}
48
+ references[value['__rustwright_cdp_object__']] = target
49
+ Hash(value['entries']).each do |key, item|
50
+ target[key] = decode_value(item, references)
51
+ end
52
+ return target
53
+ end
54
+
55
+ if value.key?('__rustwright_cdp_ref__')
56
+ return references[value['__rustwright_cdp_ref__']]
57
+ end
58
+
59
+ return nil if value.key?('__rustwright_cdp_undefined__')
60
+ return nil if value.key?('__rustwright_cdp_symbol__')
61
+ return nil if value.key?('__rustwright_cdp_function__')
62
+
63
+ if value.key?(MARKER)
64
+ return decode_unserializable(value[MARKER])
65
+ end
66
+
67
+ if value.key?('__rustwright_cdp_date__')
68
+ return Time.iso8601(value['__rustwright_cdp_date__'])
69
+ end
70
+
71
+ if value.key?('__rustwright_cdp_regexp__')
72
+ regexp = value['__rustwright_cdp_regexp__']
73
+ return Regexp.new(regexp['p'], regexp_options(regexp['f'].to_s))
74
+ end
75
+
76
+ if value.key?('__rustwright_cdp_url__')
77
+ return URI.parse(value['__rustwright_cdp_url__'])
78
+ end
79
+
80
+ if value.key?('__rustwright_cdp_error__')
81
+ return JavaScriptError.new(value['__rustwright_cdp_error__'])
82
+ end
83
+
84
+ value.each_with_object({}) do |(key, item), decoded|
85
+ decoded[key] = decode_value(item, references)
86
+ end
87
+ end
88
+ private_class_method :decode_hash
89
+
90
+ def decode_unserializable(value)
91
+ case value
92
+ when 'NaN' then Float::NAN
93
+ when 'Infinity' then Float::INFINITY
94
+ when '-Infinity' then -Float::INFINITY
95
+ when '-0' then -0.0
96
+ else
97
+ value.is_a?(String) && value.match?(/\A-?\d+n\z/) ? value[0...-1].to_i : value
98
+ end
99
+ end
100
+ private_class_method :decode_unserializable
101
+
102
+ def regexp_options(flags)
103
+ options = 0
104
+ options |= Regexp::IGNORECASE if flags.include?('i')
105
+ # JavaScript's dotAll flag is Ruby's multiline option.
106
+ options |= Regexp::MULTILINE if flags.include?('s')
107
+ options
108
+ end
109
+ private_class_method :regexp_options
110
+ end
111
+ end
data/lib/rustwright.rb ADDED
@@ -0,0 +1,479 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'monitor'
5
+ require 'rbconfig'
6
+
7
+ module Rustwright
8
+ VERSION = '0.2.0'
9
+ UNSET = Object.new.freeze
10
+
11
+ class Error < StandardError; end
12
+ class ClosedError < Error; end
13
+
14
+ module Normalize
15
+ module_function
16
+
17
+ LAUNCH_KEYS = {
18
+ 'headless' => 'headless',
19
+ 'executablePath' => 'executable_path',
20
+ 'executable_path' => 'executable_path',
21
+ 'channel' => 'channel',
22
+ 'args' => 'args',
23
+ 'ignoreAllDefaultArgs' => 'ignore_all_default_args',
24
+ 'ignore_all_default_args' => 'ignore_all_default_args',
25
+ 'ignoreDefaultArgs' => 'ignore_default_args',
26
+ 'ignore_default_args' => 'ignore_default_args',
27
+ 'timeout' => 'timeout',
28
+ 'userDataDir' => 'user_data_dir',
29
+ 'user_data_dir' => 'user_data_dir',
30
+ 'env' => 'env',
31
+ 'chromiumSandbox' => 'chromium_sandbox',
32
+ 'chromium_sandbox' => 'chromium_sandbox',
33
+ 'proxy' => 'proxy'
34
+ }.freeze
35
+
36
+ SCREENSHOT_KEYS = {
37
+ 'path' => 'path',
38
+ 'fullPage' => 'fullPage',
39
+ 'full_page' => 'fullPage',
40
+ 'clip' => 'clip',
41
+ 'timeout' => 'timeout',
42
+ 'type' => 'type',
43
+ 'quality' => 'quality',
44
+ 'omitBackground' => 'omitBackground',
45
+ 'omit_background' => 'omitBackground'
46
+ }.freeze
47
+
48
+ def launch(options)
49
+ normalize_known_hash(options || {}, LAUNCH_KEYS, 'launch options')
50
+ end
51
+
52
+ def screenshot(options)
53
+ return nil if options.nil? || options.empty?
54
+
55
+ normalize_known_hash(options, SCREENSHOT_KEYS, 'screenshot options')
56
+ end
57
+
58
+ def option_hash(options, keywords, allowed, context)
59
+ base = options.nil? ? {} : options
60
+ raise ArgumentError, "#{context} must be a Hash" unless base.is_a?(Hash)
61
+
62
+ merged = base.merge(keywords)
63
+ normalize_known_hash(merged, allowed, context)
64
+ end
65
+
66
+ def normalize_known_hash(value, mapping, context)
67
+ raise ArgumentError, "#{context} must be a Hash" unless value.is_a?(Hash)
68
+
69
+ value.each_with_object({}) do |(key, item), normalized|
70
+ source_key = key.to_s
71
+ target_key = mapping[source_key]
72
+ raise ArgumentError, "unknown #{context} key #{key.inspect}" unless target_key
73
+ raise ArgumentError, "duplicate #{context} key #{target_key.inspect}" if normalized.key?(target_key)
74
+
75
+ normalized[target_key] = json_value(item)
76
+ end
77
+ end
78
+
79
+ def json_value(value)
80
+ case value
81
+ when Hash
82
+ value.each_with_object({}) { |(key, item), result| result[key.to_s] = json_value(item) }
83
+ when Array
84
+ value.map { |item| json_value(item) }
85
+ when Symbol
86
+ value.to_s
87
+ else
88
+ value
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ require_relative 'rustwright/native'
95
+ require_relative 'rustwright/wire'
96
+
97
+ module Rustwright
98
+ class << self
99
+ def default_library_path
100
+ # Installed platform gems carry one matching native. Source checkouts keep
101
+ # the historical repository-relative target/release fallback.
102
+ bundled_library_path || source_library_path
103
+ end
104
+
105
+ def chromium(library_path: nil)
106
+ # An explicit argument or environment override remains an exact pin.
107
+ path = library_path || ENV['RUSTWRIGHT_CAPI_LIB'] || default_library_path
108
+ Chromium.new(native_for(path))
109
+ end
110
+
111
+ def bundled_library_path
112
+ platform, extension = bundled_platform
113
+ return nil unless platform
114
+
115
+ path = File.join(__dir__, 'rustwright', 'native', platform, "librustwright_capi.#{extension}")
116
+ File.file?(path) ? path : nil
117
+ end
118
+
119
+ def inline_html_url(html)
120
+ raise ArgumentError, 'html must be a String' unless html.is_a?(String)
121
+
122
+ encoded = html.encode(Encoding::UTF_8).bytes.map do |byte|
123
+ if (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) ||
124
+ (byte >= 48 && byte <= 57) || [45, 46, 95, 126].include?(byte)
125
+ byte.chr
126
+ else
127
+ format('%%%02X', byte)
128
+ end
129
+ end.join
130
+ "data:text/html;charset=utf-8,#{encoded}"
131
+ end
132
+
133
+ private
134
+
135
+ def source_library_path
136
+ extension = RbConfig::CONFIG['host_os'].match?(/darwin/) ? 'dylib' : 'so'
137
+ File.join('target', 'release', "librustwright_capi.#{extension}")
138
+ end
139
+
140
+ def bundled_platform
141
+ cpu = RbConfig::CONFIG['host_cpu']
142
+ os = RbConfig::CONFIG['host_os']
143
+
144
+ if os.match?(/darwin/)
145
+ return ['arm64-darwin', 'dylib'] if cpu.match?(/arm64|aarch64/)
146
+ return ['x86_64-darwin', 'dylib'] if cpu.match?(/x86_64|amd64/)
147
+ elsif os.match?(/linux/)
148
+ return ['aarch64-linux', 'so'] if cpu.match?(/arm64|aarch64/)
149
+ return ['x86_64-linux', 'so'] if cpu.match?(/x86_64|amd64/)
150
+ end
151
+
152
+ nil
153
+ end
154
+
155
+ def native_for(path)
156
+ expanded = File.expand_path(path)
157
+ native_mutex.synchronize do
158
+ native_instances[expanded] ||= Native.new(expanded)
159
+ end
160
+ end
161
+
162
+ def native_instances
163
+ @native_instances ||= {}
164
+ end
165
+
166
+ def native_mutex
167
+ @native_mutex ||= Mutex.new
168
+ end
169
+ end
170
+
171
+ class Chromium
172
+ def initialize(native)
173
+ @native = native
174
+ end
175
+
176
+ def executable_path
177
+ out = @native.pointer_slot
178
+ status = @native.call(:rw_chromium_executable_path, out)
179
+ @native.check_status!(status, 'rw_chromium_executable_path')
180
+ @native.copy_owned_string(@native.pointer_address(out), nullable: true)
181
+ end
182
+
183
+ def launch(options = nil, **keywords)
184
+ base = options || {}
185
+ raise ArgumentError, 'launch options must be a Hash' unless base.is_a?(Hash)
186
+
187
+ merged = base.merge(keywords)
188
+ normalized = Normalize.launch(merged)
189
+ out = @native.pointer_slot
190
+ status = @native.call(:rw_chromium_launch, JSON.generate(normalized), out)
191
+ @native.check_status!(status, 'rw_chromium_launch')
192
+ handle = @native.pointer_address(out)
193
+ raise Error, 'rw_chromium_launch returned an unexpected NULL browser' if handle.zero?
194
+
195
+ Browser.new(@native, handle)
196
+ end
197
+ end
198
+
199
+ class Browser
200
+ def initialize(native, handle)
201
+ @native = native
202
+ @handle = handle
203
+ @monitor = Monitor.new
204
+ @pages = []
205
+ end
206
+
207
+ def new_page
208
+ synchronize do
209
+ ensure_open!
210
+ out = @native.pointer_slot
211
+ status = @native.call(:rw_browser_new_page, @handle, out)
212
+ @native.check_status!(status, 'rw_browser_new_page')
213
+ page_handle = @native.pointer_address(out)
214
+ raise Error, 'rw_browser_new_page returned an unexpected NULL page' if page_handle.zero?
215
+
216
+ Page.new(@native, self, page_handle).tap { |page| @pages << page }
217
+ end
218
+ end
219
+
220
+ def ws_endpoint
221
+ synchronize do
222
+ ensure_open!
223
+ pointer = @native.call(:rw_browser_ws_endpoint, @handle)
224
+ @native.raise_null_error!('rw_browser_ws_endpoint') if @native.null?(pointer)
225
+ @native.copy_owned_string(pointer)
226
+ end
227
+ end
228
+
229
+ def close
230
+ synchronize do
231
+ return nil if @handle.nil?
232
+
233
+ errors = []
234
+ @pages.dup.each do |page|
235
+ begin
236
+ page.close
237
+ rescue StandardError => e
238
+ errors << e
239
+ end
240
+ end
241
+
242
+ handle = @handle
243
+ begin
244
+ status = @native.call(:rw_browser_close, handle)
245
+ @native.check_status!(status, 'rw_browser_close')
246
+ rescue StandardError => e
247
+ errors << e
248
+ ensure
249
+ @native.call(:rw_browser_free, handle)
250
+ @handle = nil
251
+ @pages.clear
252
+ end
253
+
254
+ raise errors.first unless errors.empty?
255
+
256
+ nil
257
+ end
258
+ end
259
+
260
+ def closed?
261
+ synchronize { @handle.nil? }
262
+ end
263
+
264
+ # Internal synchronization shared by every page belonging to this browser.
265
+ def synchronize(&block)
266
+ @monitor.synchronize(&block)
267
+ end
268
+
269
+ def remove_page(page)
270
+ synchronize { @pages.delete(page) }
271
+ end
272
+
273
+ private
274
+
275
+ def ensure_open!
276
+ raise ClosedError, 'browser is closed' if @handle.nil?
277
+ end
278
+ end
279
+
280
+ class Page
281
+ SIMPLE_TIMEOUT_KEYS = { 'timeout' => 'timeout' }.freeze
282
+ GOTO_KEYS = {
283
+ 'waitUntil' => 'wait_until',
284
+ 'wait_until' => 'wait_until',
285
+ 'timeout' => 'timeout',
286
+ 'referer' => 'referer'
287
+ }.freeze
288
+ CLOSE_KEYS = {
289
+ 'timeout' => 'timeout',
290
+ 'runBeforeUnload' => 'run_before_unload',
291
+ 'run_before_unload' => 'run_before_unload'
292
+ }.freeze
293
+
294
+ def initialize(native, browser, handle)
295
+ @native = native
296
+ @browser = browser
297
+ @handle = handle
298
+ @monitor = Monitor.new
299
+ end
300
+
301
+ def target_id
302
+ native_call do
303
+ pointer = @native.call(:rw_page_target_id, @handle)
304
+ @native.raise_null_error!('rw_page_target_id') if @native.null?(pointer)
305
+ @native.copy_owned_string(pointer)
306
+ end
307
+ end
308
+
309
+ def goto(url, options = nil, **keywords)
310
+ opts = Normalize.option_hash(options, keywords, GOTO_KEYS, 'goto options')
311
+ url = input_string(url, 'url')
312
+ wait_until = nullable_input_string(opts['wait_until'], 'wait_until')
313
+ referer = nullable_input_string(opts['referer'], 'referer')
314
+
315
+ native_call do
316
+ out = @native.pointer_slot
317
+ status = @native.call(
318
+ :rw_page_goto,
319
+ @handle,
320
+ url,
321
+ wait_until || 0,
322
+ timeout(opts['timeout']),
323
+ referer || 0,
324
+ out
325
+ )
326
+ @native.check_status!(status, 'rw_page_goto')
327
+ json = @native.copy_owned_string(@native.pointer_address(out), nullable: true)
328
+ json.nil? ? nil : JSON.parse(json)
329
+ end
330
+ end
331
+
332
+ def click(selector, options = nil, **keywords)
333
+ opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'click options')
334
+ selector = input_string(selector, 'selector')
335
+ native_call do
336
+ status = @native.call(:rw_page_click, @handle, selector, timeout(opts['timeout']))
337
+ @native.check_status!(status, 'rw_page_click')
338
+ nil
339
+ end
340
+ end
341
+
342
+ def fill(selector, value, options = nil, **keywords)
343
+ opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'fill options')
344
+ selector = input_string(selector, 'selector')
345
+ value = input_string(value, 'value')
346
+ native_call do
347
+ status = @native.call(:rw_page_fill, @handle, selector, value, timeout(opts['timeout']))
348
+ @native.check_status!(status, 'rw_page_fill')
349
+ nil
350
+ end
351
+ end
352
+
353
+ def title(options = nil, **keywords)
354
+ opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'title options')
355
+ native_call do
356
+ out = @native.pointer_slot
357
+ status = @native.call(:rw_page_title, @handle, timeout(opts['timeout']), out)
358
+ @native.check_status!(status, 'rw_page_title')
359
+ @native.copy_owned_string(@native.pointer_address(out))
360
+ end
361
+ end
362
+
363
+ def text_content(selector, options = nil, **keywords)
364
+ opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'text_content options')
365
+ selector = input_string(selector, 'selector')
366
+ native_call do
367
+ out = @native.pointer_slot
368
+ status = @native.call(:rw_page_text_content, @handle, selector, timeout(opts['timeout']), out)
369
+ @native.check_status!(status, 'rw_page_text_content')
370
+ @native.copy_owned_string(@native.pointer_address(out), nullable: true)
371
+ end
372
+ end
373
+
374
+ def evaluate(expression, argument = UNSET, options = nil, **keywords)
375
+ opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'evaluate options')
376
+ expression = input_string(expression, 'expression')
377
+ argument_json = argument.equal?(UNSET) ? nil : JSON.generate(argument)
378
+ native_call do
379
+ out = @native.pointer_slot
380
+ status = @native.call(
381
+ :rw_page_evaluate,
382
+ @handle,
383
+ expression,
384
+ argument_json || 0,
385
+ timeout(opts['timeout']),
386
+ out
387
+ )
388
+ @native.check_status!(status, 'rw_page_evaluate')
389
+ json = @native.copy_owned_string(@native.pointer_address(out))
390
+ Wire.decode(JSON.parse(json))
391
+ end
392
+ end
393
+
394
+ def screenshot(options = nil, **keywords)
395
+ base = options || {}
396
+ raise ArgumentError, 'screenshot options must be a Hash' unless base.is_a?(Hash)
397
+
398
+ merged = base.merge(keywords)
399
+ normalized = Normalize.screenshot(merged)
400
+ options_json = normalized.nil? ? nil : JSON.generate(normalized)
401
+ native_call do
402
+ out_buffer = @native.pointer_slot
403
+ out_length = @native.size_slot
404
+ status = @native.call(
405
+ :rw_page_screenshot,
406
+ @handle,
407
+ options_json || 0,
408
+ out_buffer,
409
+ out_length
410
+ )
411
+ @native.check_status!(status, 'rw_page_screenshot')
412
+ @native.copy_owned_bytes(
413
+ @native.pointer_address(out_buffer),
414
+ @native.size_value(out_length)
415
+ )
416
+ end
417
+ end
418
+
419
+ def close(options = nil, **keywords)
420
+ opts = Normalize.option_hash(options, keywords, CLOSE_KEYS, 'close options')
421
+ @browser.synchronize do
422
+ @monitor.synchronize do
423
+ return nil if @handle.nil?
424
+
425
+ handle = @handle
426
+ begin
427
+ status = @native.call(
428
+ :rw_page_close,
429
+ handle,
430
+ timeout(opts['timeout']),
431
+ opts['run_before_unload'] ? 1 : 0
432
+ )
433
+ @native.check_status!(status, 'rw_page_close')
434
+ ensure
435
+ @native.call(:rw_page_free, handle)
436
+ @handle = nil
437
+ @browser.remove_page(self)
438
+ end
439
+ end
440
+ end
441
+ nil
442
+ end
443
+
444
+ def closed?
445
+ @browser.synchronize { @monitor.synchronize { @handle.nil? } }
446
+ end
447
+
448
+ private
449
+
450
+ def native_call
451
+ @browser.synchronize do
452
+ @monitor.synchronize do
453
+ raise ClosedError, 'page is closed' if @handle.nil?
454
+ raise ClosedError, 'browser is closed' if @browser.closed?
455
+
456
+ yield
457
+ end
458
+ end
459
+ end
460
+
461
+ def timeout(value)
462
+ return Float::NAN if value.nil?
463
+
464
+ Float(value)
465
+ rescue ArgumentError, TypeError
466
+ raise ArgumentError, "timeout must be numeric, got #{value.inspect}"
467
+ end
468
+
469
+ def input_string(value, name)
470
+ raise ArgumentError, "#{name} must be a String" unless value.is_a?(String)
471
+
472
+ value.encode(Encoding::UTF_8)
473
+ end
474
+
475
+ def nullable_input_string(value, name)
476
+ value.nil? ? nil : input_string(value, name)
477
+ end
478
+ end
479
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rustwright
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: x86_64-linux
6
+ authors:
7
+ - Rustwright contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - README.md
20
+ - lib/rustwright.rb
21
+ - lib/rustwright/manifest.rb
22
+ - lib/rustwright/native.rb
23
+ - lib/rustwright/native/x86_64-linux/librustwright_capi.so
24
+ - lib/rustwright/runner.rb
25
+ - lib/rustwright/wire.rb
26
+ homepage: https://github.com/Skyvern-AI/rustwright
27
+ licenses:
28
+ - MIT
29
+ metadata: {}
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.6'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.4.19
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: Ruby bindings for the Rustwright browser automation C API
49
+ test_files: []