capybara-lightpanda 0.3.0 → 0.4.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 +40 -0
- data/README.md +3 -0
- data/lib/capybara/lightpanda/auto_scripts.rb +10 -0
- data/lib/capybara/lightpanda/binary.rb +111 -49
- data/lib/capybara/lightpanda/browser.rb +209 -308
- data/lib/capybara/lightpanda/client/web_socket.rb +24 -4
- data/lib/capybara/lightpanda/client.rb +13 -0
- data/lib/capybara/lightpanda/cookies.rb +8 -2
- data/lib/capybara/lightpanda/driver.rb +26 -4
- data/lib/capybara/lightpanda/element_extension.rb +21 -0
- data/lib/capybara/lightpanda/headers.rb +15 -0
- data/lib/capybara/lightpanda/javascripts/index.js +30 -43
- data/lib/capybara/lightpanda/keyboard.rb +18 -1
- data/lib/capybara/lightpanda/network.rb +50 -21
- data/lib/capybara/lightpanda/node.rb +63 -24
- data/lib/capybara/lightpanda/process.rb +63 -15
- data/lib/capybara/lightpanda/tasks/binary.rake +35 -0
- data/lib/capybara/lightpanda/utils/wait.rb +48 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +4 -2
- metadata +6 -4
- data/lib/capybara/lightpanda/frame.rb +0 -33
- data/lib/capybara/lightpanda/javascripts/polyfills.js +0 -212
- data/lib/capybara/lightpanda/xpath_polyfill.rb +0 -15
|
@@ -9,9 +9,9 @@ module Capybara
|
|
|
9
9
|
ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
|
|
10
10
|
|
|
11
11
|
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch/
|
|
12
|
-
# xpath/history fixes the gem now relies on:
|
|
13
|
-
# empty params + Network.getAllCookies),
|
|
14
|
-
# (window.location.pathname/.search assignment triggers navigation),
|
|
12
|
+
# xpath/history/iframe-context/dialog fixes the gem now relies on:
|
|
13
|
+
# PR #2255 (Network.clearBrowserCookies empty params + Network.getAllCookies),
|
|
14
|
+
# PR #2257 (window.location.pathname/.search assignment triggers navigation),
|
|
15
15
|
# PR #2265 (URL fragment inherited across fragment-less redirect),
|
|
16
16
|
# PR #2261 (LP.handleJavaScriptDialog pre-arm), PR #2283 (Referer on
|
|
17
17
|
# cross-page nav), PR #2292 (KeyboardEvent.keyCode/charCode), PR #2294
|
|
@@ -25,15 +25,37 @@ module Capybara
|
|
|
25
25
|
# PR #2342 (<summary> click toggles parent <details>.open),
|
|
26
26
|
# PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp),
|
|
27
27
|
# PR #2368 (events: report listener exceptions instead of halting
|
|
28
|
-
# dispatch —
|
|
28
|
+
# dispatch — load-bearing for the gem's JS bundle dispatch assumptions),
|
|
29
29
|
# PR #2289 (Page.getNavigationHistory + Page.navigateToHistoryEntry —
|
|
30
30
|
# lets us drop the history.back()/history.forward() JS workaround in
|
|
31
31
|
# Browser#back / #forward), PR #2305 (XPath 1.0: Document.evaluate,
|
|
32
32
|
# XPathResult, XPathEvaluator, XPathExpression — lets us drop the
|
|
33
|
-
# ~700 LOC XPath polyfill in javascripts/index.js)
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
33
|
+
# ~700 LOC XPath polyfill in javascripts/index.js),
|
|
34
|
+
# PR #2431 (cdp: remove duplicate Page.frameNavigated emission + reuse
|
|
35
|
+
# child frame's V8 context — fixes issue #2400 iframe contextId churn,
|
|
36
|
+
# lets us drop Browser#find_in_frame's refresh_frame_stack! rescue),
|
|
37
|
+
# PR #2445 (cdp: reset browser context arena on Target.disposeBrowserContext
|
|
38
|
+
# — restores per-spec state hygiene during Driver#reset!, cures the
|
|
39
|
+
# batch-mode pollution that PR #2431 alone exposed),
|
|
40
|
+
# PR #2435 (dom: implement HTMLDialogElement.{show, showModal, close}
|
|
41
|
+
# natively — load-bearing for the gem's HTMLDialogElement assumptions
|
|
42
|
+
# after polyfills.js was deleted),
|
|
43
|
+
# PR #2450 (forms: add enctype + 5 submitter form-* IDL accessors +
|
|
44
|
+
# text/plain submission — lets us delete polyfills.js entirely; reads
|
|
45
|
+
# of form.enctype / submitter.form{Action,Enctype,Method,NoValidate,
|
|
46
|
+
# Target} now return spec-typed values natively),
|
|
47
|
+
# PR #2478 (css: evaluate @media and matchMedia against viewport —
|
|
48
|
+
# inline <style> @media blocks now apply declarations against the
|
|
49
|
+
# hardcoded 1920×1080 viewport, and window.matchMedia(q).matches
|
|
50
|
+
# returns spec-correct booleans. Lets _lightpanda.isVisible detect
|
|
51
|
+
# inline-@media-gated hides via el.checkVisibility() without any
|
|
52
|
+
# gem-side workaround. External <link rel="stylesheet"> fetch stays
|
|
53
|
+
# out of scope by design — see .claude/rules/lightpanda-io.md
|
|
54
|
+
# limitation #6).
|
|
55
|
+
# Build 6269 = first nightly carrying PR #2478 (merge commit
|
|
56
|
+
# ab63cfbf, 2026-05-16); the 2026-05-16 nightly cut at 03:36 UTC
|
|
57
|
+
# was hours before the merge at 13:42 UTC.
|
|
58
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6269")
|
|
37
59
|
|
|
38
60
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
39
61
|
|
|
@@ -47,10 +69,11 @@ module Capybara
|
|
|
47
69
|
@stdout_w = nil
|
|
48
70
|
@stderr_r = nil
|
|
49
71
|
@stderr_w = nil
|
|
72
|
+
@finalizer_registered = false
|
|
50
73
|
end
|
|
51
74
|
|
|
52
75
|
def start
|
|
53
|
-
binary_path = @options.browser_path || Binary.
|
|
76
|
+
binary_path = @options.browser_path || Binary.update
|
|
54
77
|
|
|
55
78
|
raise BinaryNotFoundError, "Lightpanda binary not found" unless binary_path
|
|
56
79
|
|
|
@@ -170,7 +193,7 @@ module Capybara
|
|
|
170
193
|
end
|
|
171
194
|
|
|
172
195
|
def build_args
|
|
173
|
-
[
|
|
196
|
+
base = [
|
|
174
197
|
"serve",
|
|
175
198
|
"--host",
|
|
176
199
|
@options.host.to_s,
|
|
@@ -179,6 +202,8 @@ module Capybara
|
|
|
179
202
|
"--log_level",
|
|
180
203
|
"info",
|
|
181
204
|
]
|
|
205
|
+
extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
|
|
206
|
+
base + extra
|
|
182
207
|
end
|
|
183
208
|
|
|
184
209
|
def wait_for_ready
|
|
@@ -257,17 +282,29 @@ module Capybara
|
|
|
257
282
|
end
|
|
258
283
|
|
|
259
284
|
# Returns an array of PIDs holding the TCP port, [] if none, or nil if
|
|
260
|
-
# `lsof` itself isn't available on this system.
|
|
285
|
+
# `lsof` itself isn't available / usable on this system.
|
|
286
|
+
#
|
|
287
|
+
# `lsof -ti` exits 1 with empty stdout/stderr when nothing matches the
|
|
288
|
+
# filter — that's the common "port not held" case, so we treat
|
|
289
|
+
# (exit != 0, empty stdout, empty stderr) as []. A non-zero exit with
|
|
290
|
+
# stderr content is a real lsof failure (broken install, permission
|
|
291
|
+
# error, etc.); surface that as `nil` so the caller raises a clear
|
|
292
|
+
# BinaryError instead of silently retrying the start.
|
|
261
293
|
def pids_listening_on(port)
|
|
262
|
-
stdout,
|
|
263
|
-
return
|
|
294
|
+
stdout, stderr, status = Open3.capture3("lsof", "-ti", "tcp:#{port}")
|
|
295
|
+
return parse_lsof_pids(stdout) if status.success?
|
|
296
|
+
return [] if stdout.strip.empty? && stderr.strip.empty?
|
|
264
297
|
|
|
298
|
+
nil
|
|
299
|
+
rescue Errno::ENOENT
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def parse_lsof_pids(stdout)
|
|
265
304
|
stdout.split("\n").filter_map do |line|
|
|
266
305
|
pid = line.strip.to_i
|
|
267
306
|
pid.positive? ? pid : nil
|
|
268
307
|
end
|
|
269
|
-
rescue Errno::ENOENT
|
|
270
|
-
nil
|
|
271
308
|
end
|
|
272
309
|
|
|
273
310
|
# Class method so the finalizer proc doesn't capture `self` (which
|
|
@@ -285,8 +322,19 @@ module Capybara
|
|
|
285
322
|
end
|
|
286
323
|
end
|
|
287
324
|
|
|
325
|
+
# `start` may be called more than once on the same Process instance
|
|
326
|
+
# (Browser#restart_process_if_dead runs `stop` then `start` after a
|
|
327
|
+
# crash). Each `attempt_start` calls `register_finalizer`, and
|
|
328
|
+
# ObjectSpace allows multiple finalizers per object — so without
|
|
329
|
+
# this guard the second start would queue a redundant TERM-on-GC
|
|
330
|
+
# whose first invocation no-ops on ESRCH but is still pure noise.
|
|
331
|
+
# We register exactly once; the captured `pid` is overwritten by
|
|
332
|
+
# `undefine_finalizer + define_finalizer` so the finalizer always
|
|
333
|
+
# references the most recently started process.
|
|
288
334
|
def register_finalizer(pid)
|
|
335
|
+
ObjectSpace.undefine_finalizer(self) if @finalizer_registered
|
|
289
336
|
ObjectSpace.define_finalizer(self, self.class.send(:weak_kill, pid))
|
|
337
|
+
@finalizer_registered = true
|
|
290
338
|
end
|
|
291
339
|
|
|
292
340
|
def cleanup_pipes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "capybara-lightpanda"
|
|
4
|
+
|
|
5
|
+
namespace :lightpanda do
|
|
6
|
+
namespace :binary do
|
|
7
|
+
desc "Print the version of the cached Lightpanda binary"
|
|
8
|
+
task :version do
|
|
9
|
+
version = Capybara::Lightpanda::Binary.current_version
|
|
10
|
+
if version
|
|
11
|
+
puts version
|
|
12
|
+
else
|
|
13
|
+
warn "No cached Lightpanda binary at #{Capybara::Lightpanda::Binary.install_path}"
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Download the Lightpanda binary (optionally pinned: rake lightpanda:binary:update[0.3.0])"
|
|
19
|
+
task :update, [:version] do |_, args|
|
|
20
|
+
Capybara::Lightpanda::Binary.required_version = args[:version] if args[:version]
|
|
21
|
+
path = Capybara::Lightpanda::Binary.update
|
|
22
|
+
puts "Lightpanda binary ready at #{path}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Remove the cached Lightpanda binary"
|
|
26
|
+
task :remove do
|
|
27
|
+
removed = Capybara::Lightpanda::Binary.remove
|
|
28
|
+
if removed
|
|
29
|
+
puts "Removed #{removed}"
|
|
30
|
+
else
|
|
31
|
+
puts "Nothing to remove at #{Capybara::Lightpanda::Binary.install_path}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
module Utils
|
|
6
|
+
# Block-based polling helper. Borrowed from selenium-webdriver's
|
|
7
|
+
# Wait class (rb/lib/selenium/webdriver/common/wait.rb). Sibling
|
|
8
|
+
# of Utils::Attempt — Attempt retries on a specific error class,
|
|
9
|
+
# Wait loops until the block returns truthy.
|
|
10
|
+
module Wait
|
|
11
|
+
DEFAULT_INTERVAL = 0.1
|
|
12
|
+
|
|
13
|
+
# Polls the block until it returns a truthy value or `timeout`
|
|
14
|
+
# seconds elapse. Exceptions whose class is listed in `ignore`
|
|
15
|
+
# are swallowed between polls; the most recent one is appended
|
|
16
|
+
# to the timeout message so the failure stays diagnosable.
|
|
17
|
+
#
|
|
18
|
+
# @raise [Capybara::Lightpanda::TimeoutError] if the block never
|
|
19
|
+
# returns truthy within `timeout` seconds.
|
|
20
|
+
# @return the truthy value returned by the block.
|
|
21
|
+
def self.until(timeout:, interval: DEFAULT_INTERVAL, ignore: [], message: nil)
|
|
22
|
+
deadline = monotonic_time + timeout
|
|
23
|
+
last_error = nil
|
|
24
|
+
loop do
|
|
25
|
+
begin
|
|
26
|
+
result = yield
|
|
27
|
+
return result if result
|
|
28
|
+
rescue *Array(ignore) => e
|
|
29
|
+
last_error = e
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
break if monotonic_time > deadline
|
|
33
|
+
|
|
34
|
+
sleep interval
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
msg = message || "timed out after #{timeout}s"
|
|
38
|
+
msg = "#{msg} (#{last_error.message})" if last_error
|
|
39
|
+
raise Capybara::Lightpanda::TimeoutError, msg
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.monotonic_time
|
|
43
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/capybara-lightpanda.rb
CHANGED
|
@@ -10,14 +10,16 @@ require_relative "capybara/lightpanda/binary"
|
|
|
10
10
|
require_relative "capybara/lightpanda/process"
|
|
11
11
|
require_relative "capybara/lightpanda/utils/event"
|
|
12
12
|
require_relative "capybara/lightpanda/utils/attempt"
|
|
13
|
+
require_relative "capybara/lightpanda/utils/wait"
|
|
13
14
|
require_relative "capybara/lightpanda/client"
|
|
15
|
+
require_relative "capybara/lightpanda/headers"
|
|
14
16
|
require_relative "capybara/lightpanda/network"
|
|
15
17
|
require_relative "capybara/lightpanda/cookies"
|
|
16
18
|
require_relative "capybara/lightpanda/keyboard"
|
|
17
|
-
require_relative "capybara/lightpanda/frame"
|
|
18
19
|
require_relative "capybara/lightpanda/browser"
|
|
19
|
-
require_relative "capybara/lightpanda/
|
|
20
|
+
require_relative "capybara/lightpanda/auto_scripts"
|
|
20
21
|
require_relative "capybara/lightpanda/node"
|
|
22
|
+
require_relative "capybara/lightpanda/element_extension"
|
|
21
23
|
require_relative "capybara/lightpanda/driver"
|
|
22
24
|
|
|
23
25
|
module Capybara
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: capybara-lightpanda
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Navid Emad
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- NOTICE.md
|
|
72
72
|
- README.md
|
|
73
73
|
- lib/capybara-lightpanda.rb
|
|
74
|
+
- lib/capybara/lightpanda/auto_scripts.rb
|
|
74
75
|
- lib/capybara/lightpanda/binary.rb
|
|
75
76
|
- lib/capybara/lightpanda/browser.rb
|
|
76
77
|
- lib/capybara/lightpanda/client.rb
|
|
@@ -78,20 +79,21 @@ files:
|
|
|
78
79
|
- lib/capybara/lightpanda/client/web_socket.rb
|
|
79
80
|
- lib/capybara/lightpanda/cookies.rb
|
|
80
81
|
- lib/capybara/lightpanda/driver.rb
|
|
82
|
+
- lib/capybara/lightpanda/element_extension.rb
|
|
81
83
|
- lib/capybara/lightpanda/errors.rb
|
|
82
|
-
- lib/capybara/lightpanda/
|
|
84
|
+
- lib/capybara/lightpanda/headers.rb
|
|
83
85
|
- lib/capybara/lightpanda/javascripts/index.js
|
|
84
|
-
- lib/capybara/lightpanda/javascripts/polyfills.js
|
|
85
86
|
- lib/capybara/lightpanda/keyboard.rb
|
|
86
87
|
- lib/capybara/lightpanda/logger.rb
|
|
87
88
|
- lib/capybara/lightpanda/network.rb
|
|
88
89
|
- lib/capybara/lightpanda/node.rb
|
|
89
90
|
- lib/capybara/lightpanda/options.rb
|
|
90
91
|
- lib/capybara/lightpanda/process.rb
|
|
92
|
+
- lib/capybara/lightpanda/tasks/binary.rake
|
|
91
93
|
- lib/capybara/lightpanda/utils/attempt.rb
|
|
92
94
|
- lib/capybara/lightpanda/utils/event.rb
|
|
95
|
+
- lib/capybara/lightpanda/utils/wait.rb
|
|
93
96
|
- lib/capybara/lightpanda/version.rb
|
|
94
|
-
- lib/capybara/lightpanda/xpath_polyfill.rb
|
|
95
97
|
homepage: https://navidemad.github.io/capybara-lightpanda
|
|
96
98
|
licenses:
|
|
97
99
|
- MIT
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Capybara
|
|
4
|
-
module Lightpanda
|
|
5
|
-
# Lightweight metadata view of a CDP frame, populated from
|
|
6
|
-
# Page.frameAttached / Page.frameNavigated / Page.frame{Started,Stopped}Loading
|
|
7
|
-
# events. Mirrors a subset of ferrum's Frame.
|
|
8
|
-
#
|
|
9
|
-
# NOTE: this is purely introspection — Lightpanda's frame loading events
|
|
10
|
-
# are not reliable enough to drive `wait_for_navigation` (#1801, #1832),
|
|
11
|
-
# so the gem still drives navigation waits via Page.loadEventFired with
|
|
12
|
-
# readyState polling. The frame map is useful for diagnostics, listing
|
|
13
|
-
# iframes, and resolving frame metadata (name/URL) without callFunctionOn.
|
|
14
|
-
class Frame
|
|
15
|
-
STATES = %i[started_loading navigated stopped_loading detached].freeze
|
|
16
|
-
|
|
17
|
-
attr_reader :id, :parent_id
|
|
18
|
-
attr_accessor :name, :url, :state
|
|
19
|
-
|
|
20
|
-
def initialize(id, parent_id = nil, name: nil, url: nil)
|
|
21
|
-
@id = id
|
|
22
|
-
@parent_id = parent_id
|
|
23
|
-
@name = name
|
|
24
|
-
@url = url
|
|
25
|
-
@state = nil
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def main?
|
|
29
|
-
@parent_id.nil?
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
// Polyfills compensant des limitations du binaire Lightpanda.
|
|
2
|
-
// Chaque section est gardée par un test de feature : dès qu'upstream implémente
|
|
3
|
-
// l'API native, le polyfill devient un no-op et peut être retiré.
|
|
4
|
-
// Voir UPSTREAM_BUGS.md à la racine du gem pour les repros et liens d'issues.
|
|
5
|
-
(function () {
|
|
6
|
-
"use strict";
|
|
7
|
-
|
|
8
|
-
// ── Bug #7 — HTMLFormElement / HTMLButtonElement / HTMLInputElement form-* IDL gaps ──
|
|
9
|
-
// Lightpanda doesn't expose `form.enctype`, `form.method`, `form.action`,
|
|
10
|
-
// `form.target`, nor the submitter-side `formEnctype` / `formMethod` /
|
|
11
|
-
// `formAction` / `formTarget` overrides. Per WHATWG HTML these must always
|
|
12
|
-
// return a string (with the spec's missing-value default) so consumers can
|
|
13
|
-
// call `.toLowerCase()` etc. directly. Turbo's `FormSubmission` constructor
|
|
14
|
-
// does exactly that and crashes with `Cannot read properties of undefined
|
|
15
|
-
// (reading 'toLowerCase')` when it touches enctype.
|
|
16
|
-
//
|
|
17
|
-
// Polyfill strategy: only define the IDL getter when it's missing on the
|
|
18
|
-
// prototype, so a future Lightpanda nightly that adds native support wins
|
|
19
|
-
// automatically. Each getter falls back to the underlying attribute, with
|
|
20
|
-
// the spec's default if the attribute is absent. For submitter overrides
|
|
21
|
-
// (formEnctype, formMethod, etc.) we return the empty string when the
|
|
22
|
-
// override attribute is unset — Turbo and Hotwire all use the
|
|
23
|
-
// `submitter.formX || form.X` idiom, which resolves correctly when the
|
|
24
|
-
// submitter side returns "".
|
|
25
|
-
(function patchFormIDL() {
|
|
26
|
-
var ENCTYPE_VALUES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
|
|
27
|
-
function normEnctype(v) {
|
|
28
|
-
if (!v) return "application/x-www-form-urlencoded";
|
|
29
|
-
v = String(v).toLowerCase();
|
|
30
|
-
return ENCTYPE_VALUES.indexOf(v) >= 0 ? v : "application/x-www-form-urlencoded";
|
|
31
|
-
}
|
|
32
|
-
function normMethod(v) {
|
|
33
|
-
if (!v) return "get";
|
|
34
|
-
v = String(v).toLowerCase();
|
|
35
|
-
return (v === "post" || v === "dialog") ? v : "get";
|
|
36
|
-
}
|
|
37
|
-
function defineIfMissing(proto, name, getter) {
|
|
38
|
-
if (!proto || name in proto) return;
|
|
39
|
-
try { Object.defineProperty(proto, name, { configurable: true, enumerable: true, get: getter }); } catch (_) {}
|
|
40
|
-
}
|
|
41
|
-
if (typeof HTMLFormElement !== "undefined") {
|
|
42
|
-
var fp = HTMLFormElement.prototype;
|
|
43
|
-
defineIfMissing(fp, "enctype", function () { return normEnctype(this.getAttribute("enctype")); });
|
|
44
|
-
defineIfMissing(fp, "method", function () { return normMethod(this.getAttribute("method")); });
|
|
45
|
-
defineIfMissing(fp, "action", function () {
|
|
46
|
-
var a = this.getAttribute("action");
|
|
47
|
-
if (a == null || a === "") return (this.ownerDocument && this.ownerDocument.URL) || "";
|
|
48
|
-
try { return new URL(a, (this.ownerDocument && this.ownerDocument.URL) || undefined).href; }
|
|
49
|
-
catch (_) { return a; }
|
|
50
|
-
});
|
|
51
|
-
defineIfMissing(fp, "target", function () { return this.getAttribute("target") || ""; });
|
|
52
|
-
}
|
|
53
|
-
function patchSubmitter(Ctor) {
|
|
54
|
-
if (typeof Ctor === "undefined") return;
|
|
55
|
-
var p = Ctor.prototype;
|
|
56
|
-
// Empty string is the spec's missing-value default for the submitter-side
|
|
57
|
-
// IDL attrs — keep Turbo's `submitter.formX || form.X` idiom flowing
|
|
58
|
-
// through to the form's value.
|
|
59
|
-
defineIfMissing(p, "formEnctype", function () {
|
|
60
|
-
var v = this.getAttribute("formenctype");
|
|
61
|
-
return v == null ? "" : normEnctype(v);
|
|
62
|
-
});
|
|
63
|
-
defineIfMissing(p, "formMethod", function () {
|
|
64
|
-
var v = this.getAttribute("formmethod");
|
|
65
|
-
return v == null ? "" : normMethod(v);
|
|
66
|
-
});
|
|
67
|
-
defineIfMissing(p, "formAction", function () {
|
|
68
|
-
var a = this.getAttribute("formaction");
|
|
69
|
-
if (a == null || a === "") return "";
|
|
70
|
-
try { return new URL(a, (this.ownerDocument && this.ownerDocument.URL) || undefined).href; }
|
|
71
|
-
catch (_) { return a; }
|
|
72
|
-
});
|
|
73
|
-
defineIfMissing(p, "formTarget", function () { return this.getAttribute("formtarget") || ""; });
|
|
74
|
-
defineIfMissing(p, "formNoValidate", function () { return this.hasAttribute("formnovalidate"); });
|
|
75
|
-
}
|
|
76
|
-
patchSubmitter(typeof HTMLButtonElement !== "undefined" ? HTMLButtonElement : null);
|
|
77
|
-
patchSubmitter(typeof HTMLInputElement !== "undefined" ? HTMLInputElement : null);
|
|
78
|
-
})();
|
|
79
|
-
|
|
80
|
-
// ── Bug #4 — HTMLDialogElement.{showModal, show, close} non implémentés ──
|
|
81
|
-
// https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
|
|
82
|
-
if (typeof HTMLDialogElement !== "undefined") {
|
|
83
|
-
var dproto = HTMLDialogElement.prototype;
|
|
84
|
-
if (typeof dproto.showModal !== "function") {
|
|
85
|
-
dproto.showModal = function () {
|
|
86
|
-
if (this.hasAttribute("open")) {
|
|
87
|
-
throw new (window.DOMException || Error)(
|
|
88
|
-
"The element already has an 'open' attribute, and therefore cannot be opened modally.",
|
|
89
|
-
"InvalidStateError"
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
this.setAttribute("open", "");
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
if (typeof dproto.show !== "function") {
|
|
96
|
-
dproto.show = function () {
|
|
97
|
-
if (!this.hasAttribute("open")) this.setAttribute("open", "");
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
if (typeof dproto.close !== "function") {
|
|
101
|
-
dproto.close = function (returnValue) {
|
|
102
|
-
if (!this.hasAttribute("open")) return;
|
|
103
|
-
this.removeAttribute("open");
|
|
104
|
-
if (returnValue !== undefined) this.returnValue = String(returnValue);
|
|
105
|
-
this.dispatchEvent(new Event("close"));
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── Bug #8 (added 2026-05-05) — sync remove + re-add lost across dispatch phases ──
|
|
111
|
-
// WHATWG DOM specifies that each phase of a dispatch snapshots `currentTarget`'s
|
|
112
|
-
// listener list AT THAT PHASE. Listeners removed and re-added during the capture
|
|
113
|
-
// phase correctly appear in the bubble-phase snapshot in Chrome/Cuprite. Lightpanda
|
|
114
|
-
// takes the snapshot once at dispatch start, so a remove+add during capture loses
|
|
115
|
-
// the listener for the in-flight bubble. This breaks Turbo's `FormSubmitObserver`
|
|
116
|
-
// pattern, where `submitCaptured` does `remove+add` on its own `submitBubbled` to
|
|
117
|
-
// ensure that handler runs LAST in bubble — under Lightpanda, `submitBubbled` is
|
|
118
|
-
// dropped entirely and Turbo never intercepts form submissions.
|
|
119
|
-
//
|
|
120
|
-
// Native form submission via `requestSubmit()` doesn't route through JS-exposed
|
|
121
|
-
// `dispatchEvent`, so we can't detect "in-flight dispatch" by patching that. The
|
|
122
|
-
// workaround instead targets the remove+add idiom directly: defer every
|
|
123
|
-
// `removeEventListener` to a microtask. When `addEventListener` runs in the same
|
|
124
|
-
// synchronous turn with the SAME (target, type, fn, capture), we cancel the
|
|
125
|
-
// pending remove — the listener was never actually unregistered, so the in-flight
|
|
126
|
-
// bubble snapshot still contains it. Genuine removes (no matching add follows)
|
|
127
|
-
// happen at end-of-tick, indistinguishable from the unpatched behavior modulo
|
|
128
|
-
// tick boundary.
|
|
129
|
-
//
|
|
130
|
-
// Scope: only capture-phase `submit` listeners, the exact tuple Turbo Drive
|
|
131
|
-
// uses. This avoids changing observable DOM semantics for arbitrary
|
|
132
|
-
// remove/dispatch sequences elsewhere on the page — synchronous remove +
|
|
133
|
-
// dispatch outside this narrow tuple still fires the native `removeEventListener`
|
|
134
|
-
// path immediately.
|
|
135
|
-
//
|
|
136
|
-
// Trade-offs (within the narrowed scope):
|
|
137
|
-
// • Code that removes a capture-phase submit listener and then reads
|
|
138
|
-
// listener state synchronously before the microtask flush will see it
|
|
139
|
-
// as still-attached. No known framework does this on `submit`.
|
|
140
|
-
// • If something removes capture-phase submit listener X then adds Y of
|
|
141
|
-
// the same type/capture before the flush, the add happens immediately
|
|
142
|
-
// but the deferred remove fires after, removing X *after* Y is
|
|
143
|
-
// registered. Y persists, X is gone. Same end state as without the
|
|
144
|
-
// polyfill, just reordered in time.
|
|
145
|
-
(function patchListenerLifecycle() {
|
|
146
|
-
if (!window.EventTarget || !EventTarget.prototype.addEventListener) return;
|
|
147
|
-
if (typeof Promise === "undefined") return; // need microtasks
|
|
148
|
-
var origAdd = EventTarget.prototype.addEventListener;
|
|
149
|
-
var origRemove = EventTarget.prototype.removeEventListener;
|
|
150
|
-
|
|
151
|
-
function captureFlag(opts) {
|
|
152
|
-
return opts === true || (opts && typeof opts === "object" && opts.capture === true);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function inScope(type, capture) {
|
|
156
|
-
return type === "submit" && capture === true;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
var pending = []; // [{ target, type, fn, capture, cancelled }]
|
|
160
|
-
var flushScheduled = false;
|
|
161
|
-
|
|
162
|
-
function scheduleFlush() {
|
|
163
|
-
if (flushScheduled) return;
|
|
164
|
-
flushScheduled = true;
|
|
165
|
-
Promise.resolve().then(function () {
|
|
166
|
-
flushScheduled = false;
|
|
167
|
-
var queue = pending;
|
|
168
|
-
pending = [];
|
|
169
|
-
for (var i = 0; i < queue.length; i++) {
|
|
170
|
-
var r = queue[i];
|
|
171
|
-
if (r.cancelled) continue;
|
|
172
|
-
try {
|
|
173
|
-
origRemove.call(r.target, r.type, r.fn, r.capture);
|
|
174
|
-
} catch (_) {}
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
EventTarget.prototype.removeEventListener = function (type, fn, opts) {
|
|
180
|
-
var capture = captureFlag(opts);
|
|
181
|
-
if (!fn || !inScope(type, capture)) return origRemove.call(this, type, fn, opts);
|
|
182
|
-
pending.push({
|
|
183
|
-
target: this,
|
|
184
|
-
type: type,
|
|
185
|
-
fn: fn,
|
|
186
|
-
capture: capture,
|
|
187
|
-
cancelled: false,
|
|
188
|
-
});
|
|
189
|
-
scheduleFlush();
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
EventTarget.prototype.addEventListener = function (type, fn, opts) {
|
|
193
|
-
if (!fn) return origAdd.call(this, type, fn, opts);
|
|
194
|
-
var capture = captureFlag(opts);
|
|
195
|
-
if (inScope(type, capture)) {
|
|
196
|
-
// Cancel a pending remove for the same tuple (LIFO so the most recent
|
|
197
|
-
// pending remove wins for the remove-then-add idiom). Always call
|
|
198
|
-
// native add too: addEventListener is idempotent per DOM spec, and
|
|
199
|
-
// skipping risks losing the listener on a later unmatched remove.
|
|
200
|
-
for (var i = pending.length - 1; i >= 0; i--) {
|
|
201
|
-
var r = pending[i];
|
|
202
|
-
if (r.cancelled) continue;
|
|
203
|
-
if (r.target === this && r.type === type && r.fn === fn && r.capture === capture) {
|
|
204
|
-
r.cancelled = true;
|
|
205
|
-
break;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return origAdd.call(this, type, fn, opts);
|
|
210
|
-
};
|
|
211
|
-
})();
|
|
212
|
-
})();
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Capybara
|
|
4
|
-
module Lightpanda
|
|
5
|
-
module XPathPolyfill
|
|
6
|
-
JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
|
|
7
|
-
JS = File.read(JS_PATH).freeze
|
|
8
|
-
|
|
9
|
-
# Polyfills pour les APIs DOM manquantes du binaire Lightpanda.
|
|
10
|
-
# Voir UPSTREAM_BUGS.md à la racine du gem.
|
|
11
|
-
POLYFILLS_PATH = File.expand_path("javascripts/polyfills.js", __dir__).freeze
|
|
12
|
-
POLYFILLS_JS = File.read(POLYFILLS_PATH).freeze
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|