capybara-lightpanda 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +1 -1
- data/lib/capybara/lightpanda/client/web_socket.rb +45 -14
- data/lib/capybara/lightpanda/client.rb +8 -3
- data/lib/capybara/lightpanda/downloads.rb +4 -4
- data/lib/capybara/lightpanda/driver.rb +7 -0
- data/lib/capybara/lightpanda/javascripts/attach.js +1 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +47 -21
- data/lib/capybara/lightpanda/node.rb +8 -1
- data/lib/capybara/lightpanda/process.rb +16 -4
- data/lib/capybara/lightpanda/utils/wait.rb +10 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3e05fa2b4d3b676fc6dfdd88fb5c4c65b7c2d84ae979a3c6b2cc6bcfddbd712
|
|
4
|
+
data.tar.gz: ded427c3ae0148dc351d7b43ade13daf7c4f8aebc1c6492c0480b5a146d116f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a7d53b5fa0e8d971ca16b9d82a96b65c313e1663313775959b226f97d530e8c84ee19d33b4ec085983bb5d659ff262de97aa6d2501b24ffe0df25755589dae9b
|
|
7
|
+
data.tar.gz: c2bf56759b971c045a46a589c0b6bcfa31ee2de84c6f995b62731c973b99dec8b03fb9d10f22c4e4a445673ae3c1659ddfc87dd2ea19a5a32ccc9491097993d6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.0] - 2026-11-05
|
|
4
|
+
|
|
5
|
+
> **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.3** or a nightly build ≥ 11647. The driver refuses anything older and names the version it found. Pin `1.0.3` for a reproducible browser.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **`<a download>` links download natively on Lightpanda 1.0.3.** The driver used to wait for a 1.1 release before letting the browser handle a link's `download` attribute itself, so on 1.0.3 it also went to the link's URL. Every supported browser now handles it natively, and the driver leaves the download to it.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`reset_session!` no longer starts a browser just to reset it.** Resetting a session that never opened a page used to launch Lightpanda first. When that launch failed, the reset waited for the full process timeout a second time.
|
|
14
|
+
- **Contenteditable elements are detected as browsers detect them.** `contenteditable="plaintext-only"` now counts as editable. An unrecognised value such as `contenteditable="bogus"` now inherits from its parent instead of making the element editable. A page with `document.designMode = "on"` is editable throughout. SVG elements are ignored. This affects which element a click focuses and where `send_keys` types.
|
|
15
|
+
- **A stalled browser can no longer hang a command past its timeout.** The timeout used to cover only the wait for the reply, so a browser that had stopped reading could hold the call much longer while it was still being sent. Sending and waiting now share one deadline.
|
|
16
|
+
|
|
17
|
+
## [0.16.0] - 2026-10-26
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`trigger(:click)` waits for the page it loads.** Calling `element.trigger(:click)` on a link or submit button returned before the new page arrived, so the next step ran against the old page. It now waits the way `click` does.
|
|
22
|
+
- **Clicking a `<a download>` link no longer cancels the download on recent nightlies.** From nightly 11097 (and the upcoming 1.1 release line), Lightpanda starts the download itself when a link has a `download` attribute. The driver then also went to the same URL, which cancelled the download and replaced the page. The driver now leaves the download to the browser. Older browsers behave as before.
|
|
23
|
+
- **`obscured?` sees overlays inside a shadow root.** On browsers that can hit-test inside shadow trees (recent nightlies), an element covered by another element in the same shadow root now counts as obscured; before, it was always reported as not obscured. Older browsers give the same answer as before.
|
|
24
|
+
|
|
3
25
|
## [0.15.0] - 2026-10-23
|
|
4
26
|
|
|
5
27
|
> **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.2** or a nightly build ≥ 10965. The driver refuses anything older and names the version it found. Pin `1.0.2` for a reproducible browser.
|
data/README.md
CHANGED
|
@@ -57,7 +57,7 @@ driven_by :lightpanda
|
|
|
57
57
|
> Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default), and `@media` / `window.matchMedia()` evaluate against the `window_size` you configure — so a mobile-only CTA gated by `@media (max-width: …)` resolves at the width you ask for. What's missing is *layout*: nothing reflows, `getBoundingClientRect` stays synthetic, and there is no real scroll. Specs that assert on pixel geometry, scrolling, or screenshots can't pass here, nor can the two that catch people out: a second browser tab and a menu revealed purely by CSS `:hover`. See [known limitations](https://navidemad.github.io/capybara-lightpanda/docs/#limits).
|
|
58
58
|
|
|
59
59
|
> [!TIP]
|
|
60
|
-
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.
|
|
60
|
+
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.3"` (the tagged release the gem's current floor is pinned to — nightly build 11647 is that release's commit). Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
|
|
61
61
|
|
|
62
62
|
## Credits
|
|
63
63
|
|
|
@@ -5,6 +5,7 @@ require "socket"
|
|
|
5
5
|
require "websocket/driver"
|
|
6
6
|
|
|
7
7
|
require_relative "../utils/attempt"
|
|
8
|
+
require_relative "../utils/wait"
|
|
8
9
|
|
|
9
10
|
module Capybara
|
|
10
11
|
module Lightpanda
|
|
@@ -26,17 +27,27 @@ module Capybara
|
|
|
26
27
|
connect
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
#
|
|
30
|
-
# the
|
|
31
|
-
|
|
30
|
+
# The frame must be sent by `deadline` (monotonic seconds; Client#command
|
|
31
|
+
# passes the command's own deadline) or within `timeout` seconds; both
|
|
32
|
+
# nil fall back to options.timeout. A frame still queued behind the
|
|
33
|
+
# socket lock at the deadline wrote nothing, so it raises TimeoutError
|
|
34
|
+
# and the connection stays usable; one cut off mid-write kills the
|
|
35
|
+
# connection (see #write).
|
|
36
|
+
def send_message(message, timeout: nil, deadline: nil)
|
|
32
37
|
raise DeadBrowserError, "WebSocket is not open" unless @status == :open
|
|
33
38
|
|
|
39
|
+
deadline ||= Utils::Wait.deadline(timeout || @options.timeout)
|
|
34
40
|
@logger&.puts("\n\n▶ #{@logger.elapsed_time} #{message}")
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
unless acquire_driver_lock?(deadline)
|
|
42
|
+
raise TimeoutError, "Frame not sent before its deadline: another frame held the socket"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
@write_deadline = deadline
|
|
37
47
|
@driver.text(message)
|
|
38
48
|
ensure
|
|
39
|
-
@
|
|
49
|
+
@write_deadline = nil
|
|
50
|
+
@driver_mutex.unlock
|
|
40
51
|
end
|
|
41
52
|
end
|
|
42
53
|
|
|
@@ -65,11 +76,11 @@ module Capybara
|
|
|
65
76
|
# on Lightpanda's single thread) would block a plain write forever
|
|
66
77
|
# once the frame outgrows the socket buffers, holding the lock that
|
|
67
78
|
# every other command, the reader's parse and #close need — and the
|
|
68
|
-
# command
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
79
|
+
# command would never time out. So the write gives up at the frame's
|
|
80
|
+
# deadline even when the peer is still taking bytes: a trickling peer
|
|
81
|
+
# must not hold the lock past the caller's budget (ferrum #644). Close
|
|
82
|
+
# and pong frames have no command deadline; they give up once the
|
|
83
|
+
# browser takes no byte for options.timeout.
|
|
73
84
|
def write(data)
|
|
74
85
|
until data.empty?
|
|
75
86
|
written = @socket.write_nonblock(data, exception: false)
|
|
@@ -96,7 +107,9 @@ module Capybara
|
|
|
96
107
|
private
|
|
97
108
|
|
|
98
109
|
def wait_for_socket(state)
|
|
99
|
-
timeout = @
|
|
110
|
+
timeout = @write_deadline ? Utils::Wait.remaining(@write_deadline) : @options.timeout
|
|
111
|
+
return false if timeout <= 0
|
|
112
|
+
|
|
100
113
|
state == :wait_readable ? @socket.wait_readable(timeout) : @socket.wait_writable(timeout)
|
|
101
114
|
end
|
|
102
115
|
|
|
@@ -104,10 +117,28 @@ module Capybara
|
|
|
104
117
|
# mark it dead (the reader and message threads stop, pending commands
|
|
105
118
|
# are released) and close the socket.
|
|
106
119
|
def abandon_stalled_write
|
|
107
|
-
|
|
120
|
+
reason = if @write_deadline
|
|
121
|
+
"Browser did not take the whole frame before the command's deadline"
|
|
122
|
+
else
|
|
123
|
+
"Browser accepted no data for #{@options.timeout}s"
|
|
124
|
+
end
|
|
108
125
|
mark_dead(:error)
|
|
109
126
|
close_socket
|
|
110
|
-
raise DeadBrowserError, "
|
|
127
|
+
raise DeadBrowserError, "#{reason} and is likely stuck; connection closed"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Mutex has no timed lock. Poll try_lock with a short capped backoff:
|
|
131
|
+
# the lock is normally held for one frame write or one parse.
|
|
132
|
+
def acquire_driver_lock?(deadline)
|
|
133
|
+
delay = 0.0005
|
|
134
|
+
until @driver_mutex.try_lock
|
|
135
|
+
remaining = Utils::Wait.remaining(deadline)
|
|
136
|
+
return false if remaining <= 0
|
|
137
|
+
|
|
138
|
+
sleep([delay, remaining].min)
|
|
139
|
+
delay = [delay * 2, 0.01].min
|
|
140
|
+
end
|
|
141
|
+
true
|
|
111
142
|
end
|
|
112
143
|
|
|
113
144
|
def close_socket
|
|
@@ -20,21 +20,26 @@ module Capybara
|
|
|
20
20
|
start_message_thread
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# `timeout` bounds the whole command: waiting for the socket lock, writing
|
|
24
|
+
# the frame and waiting for the response all draw on one deadline, so a
|
|
25
|
+
# slow send can't stretch a command past its budget.
|
|
23
26
|
def command(method, params = {}, async: false, session_id: nil, timeout: nil)
|
|
24
27
|
message = build_message(method, params, session_id: session_id)
|
|
25
28
|
effective_timeout = timeout || @options.timeout
|
|
29
|
+
deadline = Utils::Wait.deadline(effective_timeout)
|
|
26
30
|
|
|
27
31
|
if async
|
|
28
|
-
@ws.send_message(JSON.generate(message),
|
|
32
|
+
@ws.send_message(JSON.generate(message), deadline: deadline)
|
|
29
33
|
return true
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
pending = Concurrent::IVar.new
|
|
33
37
|
@pendings[message[:id]] = pending
|
|
34
38
|
|
|
35
|
-
@ws.send_message(JSON.generate(message),
|
|
39
|
+
@ws.send_message(JSON.generate(message), deadline: deadline)
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
# value!(0) still returns a response that landed during the send.
|
|
42
|
+
response = pending.value!(Utils::Wait.remaining(deadline))
|
|
38
43
|
|
|
39
44
|
if response.nil?
|
|
40
45
|
raise DeadBrowserError, "Browser closed during #{method}" if @ws.closed?
|
|
@@ -12,10 +12,10 @@ module Capybara
|
|
|
12
12
|
# `Browser.downloadProgress`. We mirror those into a completed-files list +
|
|
13
13
|
# a blocking `wait`, the way ferrum's Downloads does.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
# type.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
15
|
+
# Navigation downloads require Content-Disposition: attachment, not just a
|
|
16
|
+
# MIME type. The floor also honors an anchor's download attribute (upstream
|
|
17
|
+
# #4124, 11097); Node#click lets that native activation finish.
|
|
18
|
+
# Capybara's MIME-only :download shared spec still stays in capybara_skip.
|
|
19
19
|
#
|
|
20
20
|
# Structure deliberately mirrors Network (same browser ref, same mutex +
|
|
21
21
|
# subscribe/unsubscribe lifecycle, same reset-after-disposeBrowserContext
|
|
@@ -322,7 +322,14 @@ module Capybara
|
|
|
322
322
|
# turns programmer errors (e.g. a NoMethodError in browser.rb) into a
|
|
323
323
|
# silent quit-and-respawn on every example with zero signal. The warn
|
|
324
324
|
# keeps repeated respawns visible.
|
|
325
|
+
#
|
|
326
|
+
# With no browser (never visited, or the launch itself failed) there is
|
|
327
|
+
# nothing to reset: `browser` would spawn Lightpanda just to reset it,
|
|
328
|
+
# and a failed launch would cost a second process timeout in teardown.
|
|
329
|
+
# The next visit launches as usual.
|
|
325
330
|
def reset!
|
|
331
|
+
return unless @browser
|
|
332
|
+
|
|
326
333
|
browser.reset
|
|
327
334
|
rescue Error, SystemCallError, IOError => e
|
|
328
335
|
warn "[capybara-lightpanda] reset! failed (#{e.class}: #{e.message}); respawning browser"
|
|
@@ -54,17 +54,24 @@ function isObscured(el) {
|
|
|
54
54
|
if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
|
|
55
55
|
var hit = doc.elementFromPoint(cx, cy);
|
|
56
56
|
if (!hit) return true;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
// hosts is the best "not obscured" answer available.
|
|
62
|
-
var root = el.getRootNode ? el.getRootNode() : doc;
|
|
57
|
+
// Document hit testing retargets a shadow child to its outermost host.
|
|
58
|
+
// Check each enclosing shadow tree from the outside in: an inner root must
|
|
59
|
+
// never let us see through an overlay covering its host in an outer root.
|
|
60
|
+
var roots = [], root = el.getRootNode ? el.getRootNode() : doc;
|
|
63
61
|
while (root && root !== doc && root.host) {
|
|
64
|
-
|
|
62
|
+
roots.push(root);
|
|
65
63
|
root = root.host.getRootNode();
|
|
66
64
|
}
|
|
67
|
-
|
|
65
|
+
for (var i = roots.length - 1; i >= 0; i--) {
|
|
66
|
+
root = roots[i];
|
|
67
|
+
if (hit !== root.host) break;
|
|
68
|
+
// Older supported builds have no shadow hit testing. A hit on our own
|
|
69
|
+
// host remains the best available evidence that the child is actionable.
|
|
70
|
+
if (typeof root.elementFromPoint !== 'function') return false;
|
|
71
|
+
hit = root.elementFromPoint(cx, cy);
|
|
72
|
+
if (!hit) return true;
|
|
73
|
+
}
|
|
74
|
+
return hit !== el && !el.contains(hit);
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
// Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
|
|
@@ -85,19 +92,32 @@ function isDisabled(el) {
|
|
|
85
92
|
return false;
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
95
|
+
// The element's own contenteditable state: true for the "", "true" and
|
|
96
|
+
// "plaintext-only" values, false for "false", null for inherit (no attribute,
|
|
97
|
+
// an invalid value, or a non-HTML element — the attribute is HTML-only).
|
|
98
|
+
// Mirrors upstream Element.contentEditableState.
|
|
99
|
+
function contentEditableState(el) {
|
|
100
|
+
if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') return null;
|
|
101
|
+
if (!el.hasAttribute || !el.hasAttribute('contenteditable')) return null;
|
|
102
|
+
var v = (el.getAttribute('contenteditable') || '').toLowerCase();
|
|
103
|
+
if (v === '' || v === 'true' || v === 'plaintext-only') return true;
|
|
104
|
+
if (v === 'false') return false;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// True if the element can be edited: the nearest contenteditable attribute in
|
|
109
|
+
// a true or false state decides, then the document's designMode. The native
|
|
110
|
+
// `isContentEditable` agrees from upstream #4210 (11229, in the floor; checked
|
|
111
|
+
// on 1.0.3), but SVG elements have no such getter and pointerFocusTarget needs
|
|
112
|
+
// each element's own state, so the walk stays.
|
|
93
113
|
function isContentEditable(el) {
|
|
94
|
-
var n = el;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
for (var n = el; n; n = n.parentNode) {
|
|
115
|
+
if (n.nodeType === 1) {
|
|
116
|
+
var state = contentEditableState(n);
|
|
117
|
+
if (state !== null) return state;
|
|
118
|
+
} else if (n.nodeType === 9) {
|
|
119
|
+
return String(n.designMode).toLowerCase() === 'on';
|
|
99
120
|
}
|
|
100
|
-
n = n.parentElement;
|
|
101
121
|
}
|
|
102
122
|
return false;
|
|
103
123
|
}
|
|
@@ -109,9 +129,15 @@ function isContentEditable(el) {
|
|
|
109
129
|
// only kind CLICK_JS can dispatch — never runs the focus default, so the
|
|
110
130
|
// driver has to apply it itself or `click` then `page.send_keys` types nowhere.
|
|
111
131
|
function pointerFocusTarget(el) {
|
|
132
|
+
// A contenteditable attribute in the true state, or the body of a
|
|
133
|
+
// designMode document (Chrome edits and focuses the body, not <html>).
|
|
112
134
|
function isEditingHost(n) {
|
|
113
|
-
|
|
114
|
-
|
|
135
|
+
var state = contentEditableState(n);
|
|
136
|
+
if (state !== null) return state;
|
|
137
|
+
var root = n.parentNode, doc = root && root.parentNode;
|
|
138
|
+
return (n.tagName || '').toUpperCase() === 'BODY' &&
|
|
139
|
+
(root.tagName || '').toUpperCase() === 'HTML' &&
|
|
140
|
+
!!doc && doc.nodeType === 9 && String(doc.designMode).toLowerCase() === 'on';
|
|
115
141
|
}
|
|
116
142
|
function isFocusable(n) {
|
|
117
143
|
if (isDisabled(n)) return false;
|
|
@@ -178,7 +178,9 @@ module Capybara
|
|
|
178
178
|
# generic Event for everything else (so callers can fire custom events
|
|
179
179
|
# like `node.trigger('lp:custom')`).
|
|
180
180
|
def trigger(event)
|
|
181
|
-
|
|
181
|
+
name = event.to_s
|
|
182
|
+
call(TRIGGER_JS, name)
|
|
183
|
+
driver.browser.wait_for_idle if name == "click"
|
|
182
184
|
end
|
|
183
185
|
|
|
184
186
|
def set(value, **_options)
|
|
@@ -658,6 +660,11 @@ module Capybara
|
|
|
658
660
|
var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
|
|
659
661
|
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
660
662
|
if (external || !notCancelled || clickEvt.defaultPrevented) return;
|
|
663
|
+
// Native activation has already started the download (upstream
|
|
664
|
+
// #4124, 11097, in the floor). Navigating again cancels it and
|
|
665
|
+
// replaces the document. Keep the complete synthetic event sequence
|
|
666
|
+
// and its cancellation, but stop here.
|
|
667
|
+
if (link && link.hasAttribute('download')) return;
|
|
661
668
|
var tag = hit.tagName;
|
|
662
669
|
// A `javascript:` link never navigates: the click's own activation
|
|
663
670
|
// behavior has already run its code. Assigning it to location.href
|
|
@@ -302,7 +302,19 @@ module Capybara
|
|
|
302
302
|
# #3658 (10657) client rects follow the scroll and
|
|
303
303
|
# DOM.scrollIntoViewIfNeeded is real — HTML5_DRAG_JS centers the
|
|
304
304
|
# drop target itself because scrollIntoView ignores `block`.
|
|
305
|
-
|
|
305
|
+
#
|
|
306
|
+
# 2026-11-05 bump 10965 -> 11647 (= release 1.0.3, the new lockstep cap;
|
|
307
|
+
# the bad-port block #4430 at 11651, CORS exposed headers #4242 at 11663
|
|
308
|
+
# and the x86_64 macOS CPU baseline #4450 at 11672 stay above it).
|
|
309
|
+
# Retired or ungated by it:
|
|
310
|
+
# #4124 (11097) native anchor downloads — Node#click no longer asks a
|
|
311
|
+
# version gate before skipping its own navigation for a download
|
|
312
|
+
# link; that gate wanted release 1.1.0 although 1.0.3 carries it.
|
|
313
|
+
# Rides along: CSS nesting #3962 (10976), blank-page timers #4148
|
|
314
|
+
# (11012), ShadowRoot hit testing #4184 (11071), same-document
|
|
315
|
+
# navigateToHistoryEntry #3903 (11113), native isContentEditable #4210
|
|
316
|
+
# (11229), contenteditable Backspace/Delete/Ctrl+A #4415 (11610).
|
|
317
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("11647")
|
|
306
318
|
|
|
307
319
|
# Lines of browser output #output keeps (ferrum keeps the same 100).
|
|
308
320
|
OUTPUT_LINES = 100
|
|
@@ -314,16 +326,16 @@ module Capybara
|
|
|
314
326
|
# bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
|
|
315
327
|
# has nothing to compare against. Releases are cut from the same trunk, so
|
|
316
328
|
# a release is acceptable exactly when its own commit count clears the
|
|
317
|
-
# nightly floor. 1.0.
|
|
329
|
+
# nightly floor. 1.0.3 (2026-11-05) is build 11647 — exactly the nightly
|
|
318
330
|
# floor, which is no accident: the floor was raised *to* the release so
|
|
319
|
-
# both channels gate the same commit. (1.0.
|
|
331
|
+
# both channels gate the same commit. (1.0.2 = 10965 predates fixes the
|
|
320
332
|
# floor exists for.)
|
|
321
333
|
#
|
|
322
334
|
# INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
|
|
323
335
|
# first release containing that build (`git rev-list --count <tag>` in the
|
|
324
336
|
# browser repo tells you). Leaving it behind would let the release channel
|
|
325
337
|
# silently accept a binary the nightly channel rejects.
|
|
326
|
-
MINIMUM_RELEASE = Gem::Version.new("1.0.
|
|
338
|
+
MINIMUM_RELEASE = Gem::Version.new("1.0.3")
|
|
327
339
|
|
|
328
340
|
class << self
|
|
329
341
|
# `lightpanda version` prints one of two shapes, and the gem supports
|
|
@@ -42,6 +42,16 @@ module Capybara
|
|
|
42
42
|
def self.monotonic_time
|
|
43
43
|
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
44
44
|
end
|
|
45
|
+
|
|
46
|
+
# A point `seconds` from now on the monotonic clock.
|
|
47
|
+
def self.deadline(seconds)
|
|
48
|
+
monotonic_time + seconds
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Seconds left before `deadline`, never negative.
|
|
52
|
+
def self.remaining(deadline)
|
|
53
|
+
[deadline - monotonic_time, 0].max
|
|
54
|
+
end
|
|
45
55
|
end
|
|
46
56
|
end
|
|
47
57
|
end
|