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 +7 -0
- data/README.md +106 -0
- data/lib/rustwright/manifest.rb +208 -0
- data/lib/rustwright/native/x86_64-linux/librustwright_capi.so +0 -0
- data/lib/rustwright/native.rb +147 -0
- data/lib/rustwright/runner.rb +188 -0
- data/lib/rustwright/wire.rb +111 -0
- data/lib/rustwright.rb +479 -0
- metadata +49 -0
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
|
|
Binary file
|
|
@@ -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: []
|