ferrum 0.17.1 → 0.18.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +9 -1369
  4. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  5. data/lib/ferrum/accessibility.rb +106 -0
  6. data/lib/ferrum/browser/binary.rb +41 -0
  7. data/lib/ferrum/browser/command.rb +20 -0
  8. data/lib/ferrum/browser/options/base.rb +69 -2
  9. data/lib/ferrum/browser/options/chrome.rb +88 -26
  10. data/lib/ferrum/browser/options/firefox.rb +32 -0
  11. data/lib/ferrum/browser/options.rb +43 -3
  12. data/lib/ferrum/browser/process.rb +56 -4
  13. data/lib/ferrum/browser/xvfb.rb +24 -0
  14. data/lib/ferrum/browser.rb +35 -9
  15. data/lib/ferrum/client/subscriber.rb +58 -0
  16. data/lib/ferrum/client/web_socket.rb +63 -12
  17. data/lib/ferrum/client.rb +206 -14
  18. data/lib/ferrum/context.rb +99 -4
  19. data/lib/ferrum/contexts.rb +136 -22
  20. data/lib/ferrum/cookies/cookie.rb +7 -1
  21. data/lib/ferrum/cookies.rb +5 -0
  22. data/lib/ferrum/dialog.rb +18 -2
  23. data/lib/ferrum/downloads.rb +52 -0
  24. data/lib/ferrum/errors.rb +62 -6
  25. data/lib/ferrum/frame/dom.rb +17 -0
  26. data/lib/ferrum/frame/runtime.rb +49 -8
  27. data/lib/ferrum/frame.rb +58 -1
  28. data/lib/ferrum/headers.rb +6 -0
  29. data/lib/ferrum/interceptable.rb +62 -0
  30. data/lib/ferrum/keyboard.rb +25 -0
  31. data/lib/ferrum/mouse.rb +6 -0
  32. data/lib/ferrum/network/auth_request.rb +81 -2
  33. data/lib/ferrum/network/error.rb +15 -0
  34. data/lib/ferrum/network/exchange.rb +10 -0
  35. data/lib/ferrum/network/intercepted_request.rb +94 -2
  36. data/lib/ferrum/network/request.rb +1 -1
  37. data/lib/ferrum/network/response.rb +16 -1
  38. data/lib/ferrum/network.rb +122 -10
  39. data/lib/ferrum/node.rb +305 -15
  40. data/lib/ferrum/page/animation.rb +7 -2
  41. data/lib/ferrum/page/frames.rb +69 -7
  42. data/lib/ferrum/page/screencast.rb +5 -0
  43. data/lib/ferrum/page/screenshot.rb +52 -20
  44. data/lib/ferrum/page/stream.rb +56 -0
  45. data/lib/ferrum/page/tracing.rb +6 -0
  46. data/lib/ferrum/page.rb +141 -46
  47. data/lib/ferrum/proxy.rb +52 -2
  48. data/lib/ferrum/rgba.rb +10 -0
  49. data/lib/ferrum/target.rb +124 -1
  50. data/lib/ferrum/utils/attempt.rb +20 -0
  51. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  52. data/lib/ferrum/utils/event.rb +14 -0
  53. data/lib/ferrum/utils/platform.rb +22 -1
  54. data/lib/ferrum/utils/thread.rb +12 -0
  55. data/lib/ferrum/version.rb +1 -1
  56. data/lib/ferrum/worker.rb +125 -0
  57. data/lib/ferrum.rb +7 -0
  58. metadata +8 -21
data/lib/ferrum/node.rb CHANGED
@@ -1,36 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
+ #
5
+ # Represents a DOM node (an element or a text node) found on a {Page} or
6
+ # within a {Frame}. Provides methods to inspect it (`text`, `property`,
7
+ # `attribute`), interact with it (`click`, `focus`, `type`, `select`) and
8
+ # search within it (`at_css`, `at_xpath`, `css`, `xpath`).
9
+ #
10
+ # @note Node identity is tied to the target it was found on; a `Node`
11
+ # fetched before a navigation cannot be used afterwards.
12
+ #
4
13
  class Node
5
14
  MOVING_WAIT_DELAY = ENV.fetch("FERRUM_NODE_MOVING_WAIT", 0.01).to_f
6
15
  MOVING_WAIT_ATTEMPTS = ENV.fetch("FERRUM_NODE_MOVING_ATTEMPTS", 50).to_i
7
16
 
8
- attr_reader :page, :target_id, :node_id, :description, :tag_name
17
+ attr_reader :page, :target_id, :description, :tag_name
9
18
 
10
- def initialize(frame, target_id, node_id, description)
19
+ def initialize(frame, target_id, description, object_id: nil, node_id: nil)
11
20
  @page = frame.page
12
21
  @target_id = target_id
13
- @node_id = node_id
14
22
  @description = description
15
23
  @tag_name = description["nodeName"].downcase
24
+ @object_id = object_id
25
+ @node_id = node_id
26
+ end
27
+
28
+ # Frontend node id is resolved lazily, on first actual need (focus, click, scroll_into_view, etc.)
29
+ # We can try to subscribe to `DOM.childNodeRemoved` and `DOM.childNodeInserted` in the future
30
+ # to keep track of nodes.
31
+ def node_id
32
+ @node_id ||= begin
33
+ id = page.command("DOM.requestNode", objectId: @object_id)["nodeId"]
34
+ raise NodeNotFoundError, "node is not trackable" if id.zero?
35
+
36
+ id
37
+ rescue NoExecutionContextError
38
+ raise NodeNotFoundError, "node is not trackable"
39
+ end
16
40
  end
17
41
 
42
+ # Whether this is an element node, as opposed to e.g. a text node.
43
+ #
44
+ # @return [Boolean]
18
45
  def node?
19
46
  description["nodeType"] == 1 # nodeType: 3, nodeName: "#text" e.g.
20
47
  end
21
48
 
49
+ #
50
+ # The id of the frame this node belongs to.
51
+ #
52
+ # @return [String]
53
+ #
22
54
  def frame_id
23
55
  description["frameId"]
24
56
  end
25
57
 
58
+ #
59
+ # The {Frame} this node belongs to. Keep using finder methods (`at_css`,
60
+ # `at_xpath`, etc.) on it to search within that frame, e.g. inside an
61
+ # `iframe`.
62
+ #
63
+ # @return [Frame, nil]
64
+ #
65
+ # @example
66
+ # frame = page.at_xpath("//iframe").frame # => Frame
67
+ # frame.at_css("//a[text() = 'Log in']") # => Node
68
+ #
26
69
  def frame
27
70
  page.frame_by(id: frame_id)
28
71
  end
29
72
 
73
+ #
74
+ # Focuses the node.
75
+ #
76
+ # @return [self]
77
+ #
78
+ # @example
79
+ # input = page.at_css("input[name='q']")
80
+ # input.focus
81
+ #
30
82
  def focus
31
83
  tap { page.command("DOM.focus", slowmoable: true, nodeId: node_id) }
32
84
  end
33
85
 
86
+ # Whether the node can receive focus. Attempts to {#focus} the node to
87
+ # find out.
88
+ #
89
+ # @return [Boolean]
34
90
  def focusable?
35
91
  focus
36
92
  true
@@ -38,6 +94,20 @@ module Ferrum
38
94
  e.message == "Element is not focusable" ? false : raise
39
95
  end
40
96
 
97
+ #
98
+ # Waits until the node's position stops changing, retrying up to
99
+ # `attempts` times. Raises {NodeMovingError} if the node is still moving
100
+ # after the last attempt.
101
+ #
102
+ # @param [Float] delay
103
+ # Seconds to wait between two position checks.
104
+ #
105
+ # @param [Integer] attempts
106
+ # Maximum number of attempts before raising.
107
+ #
108
+ # @return [Array]
109
+ # The content quads of the node once it has stopped moving.
110
+ #
41
111
  def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS)
42
112
  Utils::Attempt.with_retry(errors: NodeMovingError, max: attempts, wait: 0) do
43
113
  previous, current = content_quads_with(delay: delay)
@@ -47,15 +117,39 @@ module Ferrum
47
117
  end
48
118
  end
49
119
 
120
+ # Checks whether the node's position has stopped changing, by comparing
121
+ # two content-quad snapshots taken `delay` seconds apart.
122
+ #
123
+ # @param [Float] delay
124
+ # Seconds to wait between the two position checks.
125
+ #
126
+ # @return [Boolean]
50
127
  def moving?(delay: MOVING_WAIT_DELAY)
51
128
  previous, current = content_quads_with(delay: delay)
52
129
  previous == current
53
130
  end
54
131
 
132
+ #
133
+ # Removes focus from the node.
134
+ #
135
+ # @return [self]
136
+ #
55
137
  def blur
56
138
  tap { evaluate("this.blur()") }
57
139
  end
58
140
 
141
+ #
142
+ # Sends keystrokes to the currently focused element via the page's
143
+ # keyboard. Typically chained after {#focus} or `click`.
144
+ #
145
+ # @param [Array<String, Symbol, (Symbol, String)>] keys
146
+ # The keys to type, e.g. `"Input"`, `[:Shift, "s"], "tring"`.
147
+ #
148
+ # @return [self]
149
+ #
150
+ # @example
151
+ # input.focus.type("Input")
152
+ #
59
153
  def type(*keys)
60
154
  tap { page.keyboard.type(*keys) }
61
155
  end
@@ -67,32 +161,46 @@ module Ferrum
67
161
  x, y = find_position(**offset)
68
162
  modifiers = page.keyboard.modifiers(keys)
69
163
 
164
+ # `:right` and `:double` pass `wait: 0` to preserve the historical
165
+ # no-network-wait default of `Mouse#up` and `Mouse#down`
70
166
  case mode
71
167
  when :right
72
- page.mouse.move(x: x, y: y)
73
- page.mouse.down(button: :right, modifiers: modifiers)
74
- sleep(delay)
75
- page.mouse.up(button: :right, modifiers: modifiers)
168
+ page.mouse.click(x:, y:, modifiers:, delay:, button: :right, wait: 0)
76
169
  when :double
77
- page.mouse.move(x: x, y: y)
78
- page.mouse.down(modifiers: modifiers, count: 2)
79
- sleep(delay)
80
- page.mouse.up(modifiers: modifiers, count: 2)
170
+ page.mouse.click(x:, y:, modifiers:, delay:, count: 2, wait: 0)
81
171
  when :left
82
- page.mouse.click(x: x, y: y, modifiers: modifiers, delay: delay)
172
+ page.mouse.click(x:, y:, modifiers:, delay:)
83
173
  end
84
174
 
85
175
  self
86
176
  end
87
177
 
178
+ # Not currently implemented.
179
+ #
180
+ # @raise [NotImplementedError] always
88
181
  def hover
89
182
  raise NotImplementedError
90
183
  end
91
184
 
185
+ #
186
+ # Scrolls the node into view if it is not already visible.
187
+ #
188
+ # @return [self]
189
+ #
190
+ # @example
191
+ # page.at_css("#footer").scroll_into_view
192
+ #
92
193
  def scroll_into_view
93
194
  tap { page.command("DOM.scrollIntoViewIfNeeded", nodeId: node_id) }
94
195
  end
95
196
 
197
+ # Whether the node's bounding rect is fully within the viewport (or,
198
+ # when `of:` is given, within that scoping element's bounds).
199
+ #
200
+ # @param [Node, nil] of
201
+ # An element to use as the visible bounds instead of the window.
202
+ #
203
+ # @return [Boolean]
96
204
  def in_viewport?(of: nil)
97
205
  function = <<~JS
98
206
  function(element, scope) {
@@ -109,26 +217,94 @@ module Ferrum
109
217
  page.evaluate_func(function, self, of)
110
218
  end
111
219
 
220
+ #
221
+ # Sets files on a file input node.
222
+ #
223
+ # @param [String, Array<String>] value
224
+ # Path or paths to the file(s) to upload.
225
+ #
226
+ # @return [Hash{String => Object}]
227
+ #
228
+ # @example
229
+ # page.at_css("input[type=file]").select_file("/path/to/file.png")
230
+ #
112
231
  def select_file(value)
113
- page.command("DOM.setFileInputFiles", slowmoable: true, nodeId: node_id, files: Array(value))
114
- end
115
-
232
+ page.command(
233
+ "DOM.setFileInputFiles",
234
+ slowmoable: true,
235
+ backendNodeId: description["backendNodeId"],
236
+ files: Array(value)
237
+ )
238
+ end
239
+
240
+ #
241
+ # Finds a node by xpath, scoped to search within this node. Runs
242
+ # `document.evaluate` within this node.
243
+ #
244
+ # @param [String] selector
245
+ #
246
+ # @return [Node, nil]
247
+ #
248
+ # @example
249
+ # page.at_xpath("//iframe").at_xpath(".//a") # => Node
250
+ #
116
251
  def at_xpath(selector)
117
252
  page.at_xpath(selector, within: self)
118
253
  end
119
254
 
255
+ #
256
+ # Finds a node by CSS selector, scoped to search within this node. Runs
257
+ # `querySelector` within this node.
258
+ #
259
+ # @param [String] selector
260
+ #
261
+ # @return [Node, nil]
262
+ #
263
+ # @example
264
+ # page.at_css("form").at_css("input[name='q']") # => Node
265
+ #
120
266
  def at_css(selector)
121
267
  page.at_css(selector, within: self)
122
268
  end
123
269
 
270
+ #
271
+ # Finds nodes by xpath, scoped to search within this node. Runs
272
+ # `document.evaluate` within this node.
273
+ #
274
+ # @param [String] selector
275
+ #
276
+ # @return [Array<Node>]
277
+ #
278
+ # @example
279
+ # page.at_css("ul").xpath(".//li") # => [Node]
280
+ #
124
281
  def xpath(selector)
125
282
  page.xpath(selector, within: self)
126
283
  end
127
284
 
285
+ #
286
+ # Finds nodes by CSS selector, scoped to search within this node. Runs
287
+ # `querySelectorAll` within this node.
288
+ #
289
+ # @param [String] selector
290
+ #
291
+ # @return [Array<Node>]
292
+ #
293
+ # @example
294
+ # page.at_css("ul").css("li") # => [Node]
295
+ #
128
296
  def css(selector)
129
297
  page.css(selector, within: self)
130
298
  end
131
299
 
300
+ #
301
+ # The node's text content, i.e. `textContent`.
302
+ #
303
+ # @return [String]
304
+ #
305
+ # @example
306
+ # page.at_css("a > h3").text # => "rubycdp/ferrum: Ruby Chrome/Chromium driver - GitHub"
307
+ #
132
308
  def text
133
309
  evaluate("this.textContent")
134
310
  end
@@ -138,19 +314,52 @@ module Ferrum
138
314
  evaluate("this.innerText")
139
315
  end
140
316
 
317
+ #
318
+ # The node's `value` property. Useful for form elements such as `input`,
319
+ # `select` and `textarea`.
320
+ #
321
+ # @return [Object]
322
+ #
141
323
  def value
142
324
  evaluate("this.value")
143
325
  end
144
326
 
327
+ #
328
+ # Returns the given JavaScript property of the node.
329
+ #
330
+ # @param [String] name
331
+ #
332
+ # @return [Object]
333
+ #
334
+ # @example
335
+ # page.at_css("input").property("value") # => "Foo"
336
+ #
145
337
  def property(name)
146
338
  evaluate("this['#{name}']")
147
339
  end
148
340
  alias [] property
149
341
 
342
+ #
343
+ # Returns the value of the given HTML attribute, i.e.
344
+ # `getAttribute(name)`. Unlike {#property}, it reads the attribute as
345
+ # defined in markup rather than the live DOM property.
346
+ #
347
+ # @param [String] name
348
+ #
349
+ # @return [String, nil]
350
+ #
351
+ # @example
352
+ # page.at_css("input").attribute("value") # => "Foo"
353
+ #
150
354
  def attribute(name)
151
355
  evaluate("this.getAttribute('#{name}')")
152
356
  end
153
357
 
358
+ #
359
+ # Returns the selected `option` nodes of a `select` element.
360
+ #
361
+ # @return [Array<Node>]
362
+ #
154
363
  def selected
155
364
  function = <<~JS
156
365
  function(element) {
@@ -163,6 +372,29 @@ module Ferrum
163
372
  page.evaluate_func(function, self, on: self)
164
373
  end
165
374
 
375
+ #
376
+ # (chainable) Selects options of a `select` element by the given
377
+ # attribute.
378
+ #
379
+ # @param [Array<String>] values
380
+ # The value(s) to select. Accepts a string, multiple strings, or an
381
+ # array of strings.
382
+ #
383
+ # @param [Symbol] by
384
+ # The `option` attribute to match `values` against, e.g. `:value` or
385
+ # `:text`.
386
+ #
387
+ # @return [self]
388
+ #
389
+ # @example
390
+ # page.at_xpath("//*[select]").select(["1"]) # => Node (select)
391
+ # page.at_xpath("//*[select]").select(["text"], by: :text) # => Node (select)
392
+ #
393
+ # @example Accepts a string, multiple strings or an array of strings:
394
+ # page.at_xpath("//*[select]").select("1")
395
+ # page.at_xpath("//*[select]").select("1", "2")
396
+ # page.at_xpath("//*[select]").select(["1", "2"])
397
+ #
166
398
  def select(*values, by: :value)
167
399
  tap do
168
400
  function = <<~JS
@@ -184,10 +416,29 @@ module Ferrum
184
416
  end
185
417
  end
186
418
 
419
+ #
420
+ # Evaluates the given JavaScript expression with `this` bound to the
421
+ # node.
422
+ #
423
+ # @param [String] expression
424
+ #
425
+ # @return [Object]
426
+ #
427
+ # @example
428
+ # page.at_css("input").evaluate("this.value")
429
+ #
187
430
  def evaluate(expression)
188
431
  page.evaluate_on(node: self, expression: expression)
189
432
  end
190
433
 
434
+ #
435
+ # Two nodes are equal when they belong to the same target and share the
436
+ # same backend node id.
437
+ #
438
+ # @param [Object] other
439
+ #
440
+ # @return [Boolean]
441
+ #
191
442
  def ==(other)
192
443
  return false unless other.is_a?(Node)
193
444
 
@@ -197,10 +448,30 @@ module Ferrum
197
448
  target_id == other.target_id && description["backendNodeId"] == other.description["backendNodeId"]
198
449
  end
199
450
 
451
+ #
452
+ # A developer-friendly string representation of the node.
453
+ #
454
+ # @return [String]
455
+ #
200
456
  def inspect
201
457
  %(#<#{self.class} @target_id=#{@target_id.inspect} @node_id=#{@node_id} @description=#{@description.inspect}>)
202
458
  end
203
459
 
460
+ #
461
+ # Finds the x, y coordinates to click or hover on the node.
462
+ #
463
+ # @param [Integer, nil] x
464
+ # Horizontal offset from the reference point.
465
+ #
466
+ # @param [Integer, nil] y
467
+ # Vertical offset from the reference point.
468
+ #
469
+ # @param [Symbol] position
470
+ # `:top` to offset from the node's top-left corner, `:center` to offset
471
+ # from its center.
472
+ #
473
+ # @return [(Integer, Integer)]
474
+ #
204
475
  def find_position(x: nil, y: nil, position: :top)
205
476
  points = wait_for_stop_moving.map { |q| to_points(q) }.first
206
477
  get_position(points, x, y, position)
@@ -218,10 +489,29 @@ module Ferrum
218
489
  .each_with_object({}) { |style, memo| memo.merge!(style["name"] => style["value"]) }
219
490
  end
220
491
 
492
+ # Returns the computed accessibility node for the element, or nil if the
493
+ # element is ignored by the accessibility tree.
494
+ #
495
+ # @return [Accessibility::AXNode, nil]
496
+ def axnode
497
+ page.accessibility.node_for(self)
498
+ end
499
+
500
+ #
501
+ # Removes the node from the DOM.
502
+ #
503
+ # @return [Hash{String => Object}]
504
+ #
505
+ # @example
506
+ # page.at_css("#ad").remove
507
+ #
221
508
  def remove
222
509
  page.command("DOM.removeNode", nodeId: node_id)
223
510
  end
224
511
 
512
+ # Whether the node still exists in the DOM.
513
+ #
514
+ # @return [Boolean]
225
515
  def exists?
226
516
  page.command("DOM.resolveNode", nodeId: node_id)
227
517
  true
@@ -2,11 +2,16 @@
2
2
 
3
3
  module Ferrum
4
4
  class Page
5
+ #
6
+ # Controls the playback of CSS animations on the page via the CDP
7
+ # [Animation](https://chromedevtools.github.io/devtools-protocol/tot/Animation/)
8
+ # domain, allowing animations to be sped up, slowed down, or paused.
9
+ #
5
10
  module Animation
6
11
  #
7
12
  # Returns playback rate for CSS animations, defaults to `1`.
8
13
  #
9
- # @return [Integer]
14
+ # @return [Number]
10
15
  #
11
16
  def playback_rate
12
17
  command("Animation.getPlaybackRate")["playbackRate"]
@@ -15,7 +20,7 @@ module Ferrum
15
20
  #
16
21
  # Sets playback rate of CSS animations.
17
22
  #
18
- # @param [Integer] value
23
+ # @param [Number] value
19
24
  #
20
25
  # @example
21
26
  # browser = Ferrum::Browser.new
@@ -4,6 +4,12 @@ require "ferrum/frame"
4
4
 
5
5
  module Ferrum
6
6
  class Page
7
+ #
8
+ # Tracks the page's frame tree and keeps it in sync with the browser by
9
+ # subscribing to the relevant `Page.*`, `Network.*`, and `Runtime.*` CDP
10
+ # events. Exposes lookups over the tracked {Frame} objects and reports
11
+ # when the tree has settled into an idle state.
12
+ #
7
13
  module Frames
8
14
  # The page's main frame, the top of the tree and the parent of all frames.
9
15
  #
@@ -63,12 +69,16 @@ module Ferrum
63
69
  end
64
70
  end
65
71
 
72
+ private
73
+
66
74
  def frames_subscribe
67
75
  subscribe_frame_attached
68
76
  subscribe_frame_detached
77
+ subscribe_frame_started_navigating
69
78
  subscribe_frame_started_loading
70
79
  subscribe_frame_navigated
71
80
  subscribe_frame_stopped_loading
81
+ subscribe_frame_lifecycle_events
72
82
 
73
83
  subscribe_navigated_within_document
74
84
 
@@ -79,8 +89,6 @@ module Ferrum
79
89
  subscribe_execution_contexts_cleared
80
90
  end
81
91
 
82
- private
83
-
84
92
  def subscribe_frame_attached
85
93
  on("Page.frameAttached") do |params|
86
94
  parent_frame_id, frame_id = params.values_at("parentFrameId", "frameId")
@@ -100,6 +108,18 @@ module Ferrum
100
108
  end
101
109
  end
102
110
 
111
+ # Tracks the +loaderId+ for each navigating frame and clears stale
112
+ # lifecycle events from the previous navigation.
113
+ def subscribe_frame_started_navigating
114
+ on("Page.frameStartedNavigating") do |params|
115
+ frame = @frames[params["frameId"]]
116
+ if frame
117
+ frame.loader_id = params["loaderId"]
118
+ frame.lifecycle_events.clear
119
+ end
120
+ end
121
+ end
122
+
103
123
  def subscribe_frame_started_loading
104
124
  on("Page.frameStartedLoading") do |params|
105
125
  frame = @frames[params["frameId"]]
@@ -138,6 +158,33 @@ module Ferrum
138
158
  end
139
159
  end
140
160
 
161
+ # Appends +Page.lifecycleEvent+ events to {Frame#lifecycle_events}.
162
+ # Events from a superseded navigation (+loaderId+ mismatch) are dropped.
163
+ # Transitions +loading="lazy"+ iframes that Chrome never starts loading
164
+ # to +:stopped_loading+ on +networkIdle+, preventing a {Page#go_to} timeout.
165
+ def subscribe_frame_lifecycle_events
166
+ on("Page.lifecycleEvent") do |params|
167
+ frame = @frames[params["frameId"]]
168
+ next unless frame
169
+
170
+ frame.loader_id = params["loaderId"] unless frame.loader_id
171
+ # Reject stale events from a superseded navigation, iframes are not destroyed by Chrome, instead it creates
172
+ # new ones. Main frame stays with the same id, but new events start to flow in.
173
+ next if frame.loader_id != params["loaderId"]
174
+
175
+ event = params.slice("name", "timestamp")
176
+ frame.lifecycle_events << event
177
+
178
+ # This handles iframes with loading="lazy", those that Chrome attaches but parks outside the viewport.
179
+ # They do not trigger any events except `Page.frameAttached` and lifecycle events like:
180
+ # `init` and `networkIdle`. Without it `go_to` would wait for such iframe until it raises timeout.
181
+ if event["name"] == "networkIdle" && !frame.main?
182
+ frame.state = :stopped_loading
183
+ @event.set if idling?
184
+ end
185
+ end
186
+ end
187
+
141
188
  def subscribe_navigated_within_document
142
189
  on("Page.navigatedWithinDocument") do
143
190
  @event.set if idling?
@@ -177,21 +224,36 @@ module Ferrum
177
224
  execution_id = params["executionContextId"]
178
225
  frame = frame_by(execution_id: execution_id)
179
226
  frame&.execution_id = nil
180
- frame&.state = :stopped_loading
227
+ frame&.state = :canceled
181
228
  end
182
229
  end
183
230
 
231
+ # On full navigations/reloads Chrome fires +Page.frameAttached+ with new
232
+ # frame IDs but skips +Page.frameDetached+ for old ones. Removing stale
233
+ # child frames here prevents them from blocking {#idling?} indefinitely.
234
+ # The main frame is reset in-place; child frames are re-added via
235
+ # +Page.frameAttached+.
184
236
  def subscribe_execution_contexts_cleared
185
237
  on("Runtime.executionContextsCleared") do
186
- @frames.each_value do |f|
187
- f.execution_id = nil
188
- f.state = :stopped_loading
238
+ children = []
239
+
240
+ @frames.each do |frame_id, f|
241
+ if f.main?
242
+ f.execution_id = nil
243
+ f.loader_id = nil
244
+ f.lifecycle_events.clear
245
+ f.state = :canceled
246
+ else
247
+ children << frame_id
248
+ end
189
249
  end
250
+
251
+ children.each { |id| @frames.delete(id) }
190
252
  end
191
253
  end
192
254
 
193
255
  def idling?
194
- @frames.values.all? { |f| f.state == :stopped_loading }
256
+ @frames.values.all?(&:idle?)
195
257
  end
196
258
  end
197
259
  end
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Ferrum
4
4
  class Page
5
+ #
6
+ # Starts and stops a live screencast of the page, streaming a sequence
7
+ # of frame images (a video-like feed) to a given block as the page
8
+ # renders.
9
+ #
5
10
  module Screencast
6
11
  # Starts sending frames to record screencast to the given block.
7
12
  #