capybara-lightpanda 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +27 -0
- data/NOTICE.md +101 -0
- data/README.md +215 -0
- data/lib/capybara/lightpanda/binary.rb +190 -0
- data/lib/capybara/lightpanda/browser.rb +963 -0
- data/lib/capybara/lightpanda/client/subscriber.rb +44 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +160 -0
- data/lib/capybara/lightpanda/client.rb +124 -0
- data/lib/capybara/lightpanda/cookies.rb +181 -0
- data/lib/capybara/lightpanda/driver.rb +252 -0
- data/lib/capybara/lightpanda/errors.rb +76 -0
- data/lib/capybara/lightpanda/frame.rb +33 -0
- data/lib/capybara/lightpanda/javascripts/index.js +1108 -0
- data/lib/capybara/lightpanda/keyboard.rb +142 -0
- data/lib/capybara/lightpanda/logger.rb +37 -0
- data/lib/capybara/lightpanda/network.rb +92 -0
- data/lib/capybara/lightpanda/node.rb +726 -0
- data/lib/capybara/lightpanda/options.rb +63 -0
- data/lib/capybara/lightpanda/process.rb +252 -0
- data/lib/capybara/lightpanda/utils/event.rb +37 -0
- data/lib/capybara/lightpanda/version.rb +7 -0
- data/lib/capybara/lightpanda/xpath_polyfill.rb +10 -0
- data/lib/capybara-lightpanda.rb +42 -0
- metadata +119 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Client
|
|
6
|
+
class Subscriber
|
|
7
|
+
def initialize
|
|
8
|
+
@subscriptions = Hash.new { |h, k| h[k] = [] }
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def subscribe(event, &block)
|
|
13
|
+
@mutex.synchronize do
|
|
14
|
+
@subscriptions[event] << block
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def unsubscribe(event, block = nil)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
if block
|
|
21
|
+
@subscriptions[event].delete(block)
|
|
22
|
+
else
|
|
23
|
+
@subscriptions.delete(event)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dispatch(event, params)
|
|
29
|
+
callbacks = @mutex.synchronize { @subscriptions[event].dup }
|
|
30
|
+
|
|
31
|
+
callbacks.each { |callback| callback.call(params) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def subscribed?(event)
|
|
35
|
+
@mutex.synchronize { @subscriptions.key?(event) && @subscriptions[event].any? }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear
|
|
39
|
+
@mutex.synchronize { @subscriptions.clear }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "websocket/driver"
|
|
6
|
+
|
|
7
|
+
module Capybara
|
|
8
|
+
module Lightpanda
|
|
9
|
+
class Client
|
|
10
|
+
class WebSocket
|
|
11
|
+
attr_reader :url, :messages
|
|
12
|
+
|
|
13
|
+
def initialize(url, options)
|
|
14
|
+
@url = url
|
|
15
|
+
@options = options
|
|
16
|
+
@logger = options.logger
|
|
17
|
+
@socket = nil
|
|
18
|
+
@driver = nil
|
|
19
|
+
@thread = nil
|
|
20
|
+
@status = :closed
|
|
21
|
+
@messages = Queue.new
|
|
22
|
+
@driver_mutex = Mutex.new
|
|
23
|
+
|
|
24
|
+
connect
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def send_message(message)
|
|
28
|
+
raise DeadBrowserError, "WebSocket is not open" unless @status == :open
|
|
29
|
+
|
|
30
|
+
@logger&.puts("\n\n▶ #{@logger.elapsed_time} #{message}")
|
|
31
|
+
@driver_mutex.synchronize { @driver.text(message) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def close
|
|
35
|
+
return if @status == :closed
|
|
36
|
+
|
|
37
|
+
@status = :closing
|
|
38
|
+
@messages.close
|
|
39
|
+
@driver&.close
|
|
40
|
+
@thread&.join(1) || @thread&.kill
|
|
41
|
+
@socket&.close
|
|
42
|
+
@status = :closed
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def closed?
|
|
46
|
+
@status == :closed
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def open?
|
|
50
|
+
@status == :open
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def write(data)
|
|
54
|
+
@socket.write(data)
|
|
55
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
56
|
+
@status = :closed
|
|
57
|
+
@messages.close
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def connect
|
|
63
|
+
uri = URI.parse(@url)
|
|
64
|
+
|
|
65
|
+
@socket = connect_with_retry(uri.host, uri.port)
|
|
66
|
+
@driver = ::WebSocket::Driver.client(self)
|
|
67
|
+
|
|
68
|
+
setup_callbacks
|
|
69
|
+
|
|
70
|
+
@driver.start
|
|
71
|
+
|
|
72
|
+
read_handshake_response
|
|
73
|
+
start_reader_thread
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def connect_with_retry(host, port, retries: 10, delay: 0.1)
|
|
77
|
+
retries.times do |i|
|
|
78
|
+
return TCPSocket.new(host, port)
|
|
79
|
+
rescue Errno::ECONNREFUSED
|
|
80
|
+
raise if i == retries - 1
|
|
81
|
+
|
|
82
|
+
sleep delay
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def setup_callbacks
|
|
87
|
+
@driver.on(:open) do
|
|
88
|
+
@status = :open
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@driver.on(:message) do |event|
|
|
92
|
+
@logger&.puts(" ◀ #{@logger.elapsed_time} #{event.data}\n")
|
|
93
|
+
message = parse_message(event.data)
|
|
94
|
+
@messages << message if message
|
|
95
|
+
rescue ClosedQueueError
|
|
96
|
+
# Queue was closed during shutdown
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@driver.on(:close) do
|
|
100
|
+
@status = :closed
|
|
101
|
+
@messages.close
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@driver.on(:error) do |event|
|
|
105
|
+
@status = :error
|
|
106
|
+
@messages.close
|
|
107
|
+
|
|
108
|
+
raise DeadBrowserError, "WebSocket error: #{event.message}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def start_reader_thread
|
|
113
|
+
@thread = Thread.new do
|
|
114
|
+
Thread.current.abort_on_exception = true
|
|
115
|
+
|
|
116
|
+
loop do
|
|
117
|
+
break if @status == :closed || @status == :closing
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
next unless @socket.wait_readable(0.1)
|
|
121
|
+
|
|
122
|
+
data = @socket.readpartial(4096)
|
|
123
|
+
@driver_mutex.synchronize { @driver.parse(data) }
|
|
124
|
+
rescue Errno::ECONNRESET, Errno::EPIPE, IOError
|
|
125
|
+
@status = :closed
|
|
126
|
+
@messages.close
|
|
127
|
+
break
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def read_handshake_response
|
|
134
|
+
started_at = Time.now
|
|
135
|
+
|
|
136
|
+
while @status != :open && Time.now - started_at < @options.timeout
|
|
137
|
+
next unless @socket.wait_readable(0.1)
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
data = @socket.readpartial(4096)
|
|
141
|
+
@driver.parse(data)
|
|
142
|
+
rescue EOFError
|
|
143
|
+
raise DeadBrowserError, "Connection closed during handshake"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
raise TimeoutError, "WebSocket connection timeout" unless @status == :open
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def parse_message(data)
|
|
151
|
+
JSON.parse(data, max_nesting: false)
|
|
152
|
+
rescue JSON::ParserError => e
|
|
153
|
+
warn "Failed to parse WebSocket message: #{e.message}"
|
|
154
|
+
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "concurrent-ruby"
|
|
5
|
+
|
|
6
|
+
require_relative "client/web_socket"
|
|
7
|
+
require_relative "client/subscriber"
|
|
8
|
+
|
|
9
|
+
module Capybara
|
|
10
|
+
module Lightpanda
|
|
11
|
+
class Client
|
|
12
|
+
attr_reader :ws_url, :options
|
|
13
|
+
|
|
14
|
+
def initialize(ws_url, options)
|
|
15
|
+
@ws_url = ws_url
|
|
16
|
+
@options = options
|
|
17
|
+
@ws = WebSocket.new(ws_url, options)
|
|
18
|
+
@command_id = 0
|
|
19
|
+
@pendings = Concurrent::Hash.new
|
|
20
|
+
@subscriber = Subscriber.new
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
|
|
23
|
+
start_message_thread
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def command(method, params = {}, async: false, session_id: nil, timeout: nil)
|
|
27
|
+
message = build_message(method, params, session_id: session_id)
|
|
28
|
+
|
|
29
|
+
if async
|
|
30
|
+
@ws.send_message(JSON.generate(message))
|
|
31
|
+
return true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
pending = Concurrent::IVar.new
|
|
35
|
+
@pendings[message[:id]] = pending
|
|
36
|
+
|
|
37
|
+
@ws.send_message(JSON.generate(message))
|
|
38
|
+
|
|
39
|
+
effective_timeout = timeout || @options.timeout
|
|
40
|
+
response = pending.value!(effective_timeout)
|
|
41
|
+
|
|
42
|
+
if response.nil?
|
|
43
|
+
raise DeadBrowserError, "Browser closed during #{method}" if @ws.closed?
|
|
44
|
+
|
|
45
|
+
raise TimeoutError, "Command #{method} timed out after #{effective_timeout}s"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
handle_error(response) if response["error"]
|
|
49
|
+
|
|
50
|
+
response["result"]
|
|
51
|
+
ensure
|
|
52
|
+
@pendings.delete(message[:id]) if message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on(event, &)
|
|
56
|
+
@subscriber.subscribe(event, &)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def off(event, block = nil)
|
|
60
|
+
@subscriber.unsubscribe(event, block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
@ws&.close
|
|
65
|
+
@message_thread&.join(1) || @message_thread&.kill
|
|
66
|
+
@subscriber.clear
|
|
67
|
+
@pendings.clear
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def closed?
|
|
71
|
+
@ws.closed?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_message(method, params, session_id: nil)
|
|
77
|
+
id = next_command_id
|
|
78
|
+
message = { id: id, method: method, params: params }
|
|
79
|
+
message[:sessionId] = session_id if session_id
|
|
80
|
+
|
|
81
|
+
message
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def next_command_id
|
|
85
|
+
@mutex.synchronize { @command_id += 1 }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def start_message_thread
|
|
89
|
+
@message_thread = Thread.new do
|
|
90
|
+
Thread.current.abort_on_exception = true
|
|
91
|
+
|
|
92
|
+
while (message = @ws.messages.pop)
|
|
93
|
+
handle_message(message)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle_message(message)
|
|
99
|
+
if message["id"]
|
|
100
|
+
pending = @pendings[message["id"]]
|
|
101
|
+
pending&.set(message)
|
|
102
|
+
elsif message["method"]
|
|
103
|
+
@subscriber.dispatch(message["method"], message["params"])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def handle_error(response)
|
|
108
|
+
error = response["error"]
|
|
109
|
+
message = error["message"]
|
|
110
|
+
|
|
111
|
+
case message
|
|
112
|
+
when /No node with given id found/i
|
|
113
|
+
raise NodeNotFoundError, message
|
|
114
|
+
when /Cannot find context with specified id/i,
|
|
115
|
+
/Execution context was destroyed/i,
|
|
116
|
+
/Cannot find default execution context/i
|
|
117
|
+
raise NoExecutionContextError, message
|
|
118
|
+
else
|
|
119
|
+
raise BrowserError, error
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Capybara
|
|
6
|
+
module Lightpanda
|
|
7
|
+
class Cookies
|
|
8
|
+
# Typed wrapper around a CDP cookie hash so callers don't have to remember
|
|
9
|
+
# the camelCase keys (`httpOnly`, `sameSite`, …) the CDP returns. Mirrors
|
|
10
|
+
# ferrum's Cookies::Cookie. `attributes` exposes the raw hash for callers
|
|
11
|
+
# that still need it (e.g. YAML serialization in store/load).
|
|
12
|
+
class Cookie
|
|
13
|
+
attr_reader :attributes
|
|
14
|
+
|
|
15
|
+
def initialize(attributes)
|
|
16
|
+
@attributes = attributes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name = attributes["name"]
|
|
20
|
+
def value = attributes["value"]
|
|
21
|
+
def domain = attributes["domain"]
|
|
22
|
+
def path = attributes["path"]
|
|
23
|
+
def samesite = attributes["sameSite"]
|
|
24
|
+
def size = attributes["size"]
|
|
25
|
+
def secure? = attributes["secure"]
|
|
26
|
+
def httponly? = attributes["httpOnly"]
|
|
27
|
+
def session? = attributes["session"]
|
|
28
|
+
|
|
29
|
+
alias same_site samesite
|
|
30
|
+
alias http_only? httponly?
|
|
31
|
+
|
|
32
|
+
# Time when the cookie expires, or nil for session cookies (CDP reports
|
|
33
|
+
# session cookies with `expires: -1`).
|
|
34
|
+
def expires
|
|
35
|
+
exp = attributes["expires"]
|
|
36
|
+
Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ==(other)
|
|
40
|
+
other.is_a?(self.class) && other.attributes == attributes
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
alias eql? ==
|
|
44
|
+
|
|
45
|
+
def hash
|
|
46
|
+
attributes.hash
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
attributes
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :browser
|
|
55
|
+
|
|
56
|
+
def initialize(browser)
|
|
57
|
+
@browser = browser
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def all
|
|
61
|
+
result = browser.command("Network.getCookies")
|
|
62
|
+
(result["cookies"] || []).map { |c| Cookie.new(c) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get(name)
|
|
66
|
+
all.find { |cookie| cookie.name == name }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
|
|
70
|
+
params = {
|
|
71
|
+
name: name,
|
|
72
|
+
value: value,
|
|
73
|
+
path: path,
|
|
74
|
+
secure: secure,
|
|
75
|
+
httpOnly: http_only,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
params[:domain] = domain if domain
|
|
79
|
+
params[:expires] = expires.to_i if expires
|
|
80
|
+
|
|
81
|
+
browser.command("Network.setCookie", **params)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def remove(name:, domain: nil, path: "/")
|
|
85
|
+
params = { name: name, path: path }
|
|
86
|
+
params[:domain] = domain if domain
|
|
87
|
+
|
|
88
|
+
browser.command("Network.deleteCookies", **params)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Lightpanda gotchas observed on current nightly:
|
|
92
|
+
# * `Network.clearBrowserCookies` raises `InvalidParams` (so it does NOT
|
|
93
|
+
# clear anything despite the upstream PR #1821 / >= v0.2.6 note).
|
|
94
|
+
# * `Network.getCookies` (no `urls` param) is scoped to the CURRENT
|
|
95
|
+
# page's origin — cookies set on previously-visited domains are
|
|
96
|
+
# invisible from a different page.
|
|
97
|
+
# * `Network.getCookies` on `about:blank` raises `InvalidDomain`.
|
|
98
|
+
#
|
|
99
|
+
# To honor Capybara's `reset_session! removes ALL cookies` contract
|
|
100
|
+
# across multiple test domains (e.g. `localhost` AND `127.0.0.1`), we
|
|
101
|
+
# iterate every origin Browser has navigated to and per-origin call
|
|
102
|
+
# `Network.getCookies(urls: [origin])` then `Network.deleteCookies(url:)`.
|
|
103
|
+
# The bulk-clear call is still attempted first as a fast path / future-
|
|
104
|
+
# proofing for when upstream fixes it.
|
|
105
|
+
def clear
|
|
106
|
+
begin
|
|
107
|
+
browser.command("Network.clearBrowserCookies")
|
|
108
|
+
rescue BrowserError, TimeoutError, StandardError
|
|
109
|
+
# InvalidParams on current nightly; pre-v0.2.6 used to crash the
|
|
110
|
+
# WebSocket. Either way, fall through to per-origin sweep.
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
sweep_visited_origins
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Persist all current cookies to a YAML file (ferrum parity).
|
|
117
|
+
# Returns the number of bytes written.
|
|
118
|
+
def store(path = "cookies.yml")
|
|
119
|
+
File.write(path, all.map(&:to_h).to_yaml)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Load cookies from a YAML file produced by `store` and re-set them.
|
|
123
|
+
# CDP requires either domain or url for each cookie; entries from `store`
|
|
124
|
+
# already include domain, so they round-trip cleanly. Returns true on
|
|
125
|
+
# success (intentionally not a predicate — mirrors ferrum's API).
|
|
126
|
+
def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
|
|
127
|
+
cookies = YAML.load_file(path)
|
|
128
|
+
cookies.each { |c| restore_cookie(c) }
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def sweep_visited_origins
|
|
135
|
+
origins = browser.visited_origins.to_a
|
|
136
|
+
return if origins.empty?
|
|
137
|
+
|
|
138
|
+
result = browser.command("Network.getCookies", urls: origins)
|
|
139
|
+
cookies = result["cookies"] || []
|
|
140
|
+
cookies.each do |cookie|
|
|
141
|
+
# CDP needs either domain or url; build a url from the cookie's
|
|
142
|
+
# own domain+path so we don't mismatch (e.g. cookie domain `.x.test`
|
|
143
|
+
# against an origin we tracked as `https://x.test:443`).
|
|
144
|
+
url = cookie_url(cookie)
|
|
145
|
+
params = { name: cookie["name"] }
|
|
146
|
+
params[:url] = url if url
|
|
147
|
+
params[:domain] = cookie["domain"] unless url
|
|
148
|
+
browser.command("Network.deleteCookies", **params)
|
|
149
|
+
end
|
|
150
|
+
rescue StandardError
|
|
151
|
+
# Connection lost or origin no longer valid; nothing more to do.
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def cookie_url(cookie)
|
|
155
|
+
domain = cookie["domain"].to_s.sub(/\A\./, "")
|
|
156
|
+
return nil if domain.empty?
|
|
157
|
+
|
|
158
|
+
scheme = cookie["secure"] ? "https" : "http"
|
|
159
|
+
path = cookie["path"] || "/"
|
|
160
|
+
"#{scheme}://#{domain}#{path}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# set() takes keyword args, but YAML round-trips give us a hash with the
|
|
164
|
+
# raw CDP keys (camelCase). Normalize and forward.
|
|
165
|
+
def restore_cookie(hash)
|
|
166
|
+
attrs = hash.transform_keys(&:to_s)
|
|
167
|
+
params = {
|
|
168
|
+
name: attrs["name"],
|
|
169
|
+
value: attrs["value"],
|
|
170
|
+
path: attrs["path"] || "/",
|
|
171
|
+
secure: attrs["secure"] || false,
|
|
172
|
+
http_only: attrs["httpOnly"] || false,
|
|
173
|
+
}
|
|
174
|
+
params[:domain] = attrs["domain"] if attrs["domain"]
|
|
175
|
+
exp = attrs["expires"]
|
|
176
|
+
params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
|
|
177
|
+
set(**params)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|