capybara-simulated 0.7.0 → 0.9.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.
@@ -364,6 +364,10 @@ module Capybara
364
364
  # Browser's `@current_realm_id` may still point at it; the Browser uses
365
365
  # this to raise a stale-element instead of running a frame handle op
366
366
  # against the main registry.
367
+ # Per-frame browsing contexts (nested realms for iframes / aux windows) are a V8-engine
368
+ # feature; QuickJS keeps a same-realm fallback. `within_frame` gates on this.
369
+ def supports_frames? = true
370
+
367
371
  # Ids of every live frame / window realm in this isolate (excludes the main
368
372
  # realm, id 0). Used to fan a BroadcastChannel post out to sibling realms.
369
373
  def frame_realm_ids = frame_realms.keys
@@ -422,6 +426,12 @@ module Capybara
422
426
  # through its own navigation.
423
427
  def window_realm_meta = (@window_realm_meta ||= {})
424
428
 
429
+ # Is this realm a TOP-LEVEL browsing context? The main realm is, and so is an auxiliary
430
+ # window realm — `frame_realm_parents` records 0 for one because it has no parent FRAME,
431
+ # which is not the same as being nested in the main window. An ancestor walk (the focus
432
+ # chain, most of all) has to stop here rather than step into the opener.
433
+ def top_level_realm?(realm_id) = realm_id.to_i.zero? || window_realm_meta.key?(realm_id.to_i)
434
+
425
435
  def dispose_frame_realms
426
436
  @realm_module_handles&.clear
427
437
  @frame_realm_depths&.clear
@@ -440,10 +450,22 @@ module Capybara
440
450
  # keeps the new realm's `parent`/`top` wired to the owning realm. The
441
451
  # Browser then re-points the iframe element at the new id (`__csimRebindFrameRealm`).
442
452
  def reload_frame_realm(old_id, parent_id, url, body, content_type)
443
- dispose_frame_realm(old_id)
453
+ # A re-navigated document discards its child browsing contexts, so dispose the old realm's
454
+ # DESCENDANT frame realms too — not just old_id. The JS src-reassignment path gets this for
455
+ # free (the old document's iframe elements go away → DOM-unregister disposes their realms);
456
+ # the Ruby reload path (navigate_realm_self_get/_post) rebuilds without that DOM teardown, so
457
+ # a descendant frame's realm would otherwise linger and its contentWindow stay live.
458
+ dispose_frame_realm_tree(old_id)
444
459
  create_frame_realm(ctx, url, body, content_type, parent_id)
445
460
  end
446
461
 
462
+ # Dispose a frame realm and every descendant frame realm (transitively), deepest first so a
463
+ # parent's child-realm set is consistent as each level is torn down.
464
+ def dispose_frame_realm_tree(id)
465
+ frame_realm_parents.select {|_child, parent| parent == id }.each_key {|child| dispose_frame_realm_tree(child) }
466
+ dispose_frame_realm(id)
467
+ end
468
+
447
469
  # Navigate a window realm (`win.location = …`): a FRESH realm for the new
448
470
  # document, like reload_frame_realm — an in-place `__csimLoadDocument` on an
449
471
  # already-loaded realm does NOT re-run inline scripts, but a fresh realm does.
@@ -452,8 +474,14 @@ module Capybara
452
474
  # the window after navigating it; a stable WindowProxy is future work.)
453
475
  def reload_window_realm(old_id, url, body, content_type)
454
476
  meta = window_realm_meta[old_id] || {}
455
- dispose_frame_realm(old_id)
456
- create_window_realm(url, body, content_type, opener_id: meta[:opener_id], window_name: meta[:window_name])
477
+ # Like reload_frame_realm, a re-navigated window discards its child browsing contexts — dispose
478
+ # the descendant frame realms too, not just old_id, so a popup's iframes don't linger.
479
+ dispose_frame_realm_tree(old_id)
480
+ create_window_realm(
481
+ url, body, content_type,
482
+ opener_id: meta[:opener_id], window_name: meta[:window_name],
483
+ about_base: meta[:about_base], about_origin: meta[:about_origin]
484
+ )
457
485
  end
458
486
 
459
487
  # Tear down a single frame realm (e.g. a descendant frame destroyed when an
@@ -463,6 +491,13 @@ module Capybara
463
491
  # The frame's browsing context is going away — revoke the blob URLs it
464
492
  # created (url-lifetime "Removing an iframe").
465
493
  @browser.revoke_realm_blobs(id) rescue nil
494
+ # Drop it from the SW Client registry so matchAll stops returning a dead client.
495
+ @browser.sw_unregister_client(id) rescue nil
496
+ # A dedicated worker is owned by the context that created it — discarding the context
497
+ # terminates it (and unregisters its own client record).
498
+ @browser.terminate_realm_workers(id) rescue nil
499
+ # If it held the focus chain, focus returns to the top-level browsing context.
500
+ @browser.note_realm_discarded(id) rescue nil
466
501
  @realm_module_handles&.delete(id)
467
502
  @frame_realm_depths&.delete(id)
468
503
  @frame_realm_parents&.delete(id)
@@ -733,8 +768,8 @@ module Capybara
733
768
  # id (or nil on failure — then the bridge keeps its same-realm fallback).
734
769
  # The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
735
770
  def attach_frame_realm_loader(c)
736
- c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil) {
737
- RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id, frame_name, frame_doc_origin, frame_location_origin, js_url_source) }
771
+ c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil) {
772
+ RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id, frame_name, frame_doc_origin, frame_location_origin, js_url_source, frame_about_base, frame_viewport) }
738
773
  })
739
774
  # Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
740
775
  # the bridge calls this to tear down the superseded one so it doesn't
@@ -760,6 +795,15 @@ module Capybara
760
795
 
761
796
  def frame_realm_depths = (@frame_realm_depths ||= {})
762
797
 
798
+ # A frame whose document URL is opaque — about:blank (empty src), about:srcdoc,
799
+ # or empty — has no scope of its own; its controller is inherited from the
800
+ # creator. (create_frame_realm receives 'about:blank'/'about:srcdoc' as the url
801
+ # for these, seeded by __csimFrameWindow.)
802
+ def opaque_frame_url?(url)
803
+ u = url.to_s
804
+ u.empty? || u.start_with?('about:')
805
+ end
806
+
763
807
  # Bring a freshly-created realm context up to a runnable bridge, shared by the
764
808
  # frame and window realm constructors. Re-evaling the snapshot source would
765
809
  # redefine snapshot globals (e.g. the `scrollX` accessor) and throw, so only
@@ -777,7 +821,7 @@ module Capybara
777
821
  realm
778
822
  end
779
823
 
780
- def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil)
824
+ def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil)
781
825
  depth = (frame_realm_depths[parent_id] || 0) + 1
782
826
  if depth > MAX_FRAME_DEPTH
783
827
  @browser.log_console('warn', "iframe nesting depth #{depth} exceeds #{MAX_FRAME_DEPTH}; not building #{url}")
@@ -829,6 +873,11 @@ module Capybara
829
873
  # loads, so a frame whose load handler reads window.name to identify itself
830
874
  # (declarative-shadow declarative-child-frame) sees it.
831
875
  realm.call('__csimSetWindowName', frame_name.to_s) unless frame_name.nil?
876
+ # The frame's viewport is its container's content box, measured by the parent realm. Seed it
877
+ # BEFORE the document loads so load-time `innerWidth` reads and `@media` evaluation see the
878
+ # frame's own size rather than the top window's. `nil` = an unrendered container, which is a
879
+ # 0x0 window — pass it through rather than skipping, or the frame keeps the top-level size.
880
+ realm.call('__csimFrameViewportChanged', frame_viewport)
832
881
  # Seed the frame's document origin (opaque/inherited) BEFORE the document
833
882
  # loads, so its load-time scripts read the right self.origin. nil → a
834
883
  # real-URL frame whose origin is its own location origin.
@@ -836,7 +885,47 @@ module Capybara
836
885
  # The frame's location.origin (opaque "null" for about:blank / srcdoc /
837
886
  # javascript:); decoupled from the location string so navigation is intact.
838
887
  realm.call('__csimSetLocationOrigin', frame_location_origin.to_s) unless frame_location_origin.nil?
888
+ # An about:blank / about:srcdoc frame's URL is opaque, but its base URL (for
889
+ # relative-URL resolution) is INHERITED from the creator. Seed that inherited
890
+ # base BEFORE the document loads so its load-time scripts resolve relative URLs
891
+ # against the parent, even though location.href reports about:blank/srcdoc.
892
+ realm.call('__csimSetAboutBaseURL', frame_about_base.to_s) unless frame_about_base.to_s.empty?
893
+ # If a service worker's scope covers this frame's URL, the frame is a CONTROLLED client:
894
+ # wire its `navigator.serviceWorker.controller` BEFORE its document loads, so the frame's
895
+ # load-time scripts see the controller and route their subresource fetch() / XHR / EventSource
896
+ # through the SW's fetch event (fetch-event-network-error's controllee fires XHRs during load).
897
+ # The frame never called register(), so its per-realm registration Map is empty — set the
898
+ # controller directly from Ruby's scope→handle mirror. An OPAQUE child (about:blank / srcdoc)
899
+ # has no scope of its own, so it INHERITS its parent frame's controller (HTML: an about:blank
900
+ # document is controlled by its creator). Inheritance chains through controlled FRAME realms
901
+ # (sw_note_realm_controller); recording it BEFORE the load also lets a NESTED frame built during
902
+ # this frame's load inherit the controller.
903
+ opaque = opaque_frame_url?(url)
904
+ # A sandboxed frame WITHOUT allow-same-origin has an OPAQUE origin (doc origin 'null'), which
905
+ # is cross-origin to everything — so it is NOT CONTROLLED AT ALL. It inherits no controller
906
+ # from its creator (srcdoc-iframe: "sandboxed srcdoc should not inherit"), its subresource
907
+ # fetches never reach the SW, and it is absent from `clients.matchAll()`. Excluding it from
908
+ # only the client registry would not hold: a controlled frame's first subresource fetch
909
+ # re-registers it lazily from the fetch event (js/src/workers.js clientFor). The SW does still
910
+ # answer its NAVIGATION request, because a CSP-header sandbox is knowable only FROM that
911
+ # response (sandboxed-iframe-fetch-event, "Service worker should NOT control the sandboxed
912
+ # page"). allow-same-origin restores the parent origin, and a real-URL frame carries no
913
+ # explicit origin at all (nil = its own location origin) — both are controlled normally.
914
+ if frame_doc_origin.to_s != 'null'
915
+ ctrl = @browser.sw_client_controller_for(url.to_s)
916
+ ctrl ||= @browser.sw_inherited_controller_for(parent_id) if opaque
917
+ if ctrl
918
+ # Wiring the controller is what registers this frame as a client — the realm reports
919
+ # itself from installController (js/src/sw-client.js), before the document below loads.
920
+ realm.call('__csim_swSetControllerDirect', *ctrl)
921
+ @browser.sw_note_realm_controller(realm.id, ctrl)
922
+ end
923
+ end
839
924
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
925
+ # This document now has a URL and any host-wired controller, so it can announce itself
926
+ # as a service-worker client — an UNCONTROLLED context is still a client of its origin
927
+ # and must show up in `matchAll({includeUncontrolled: true})`.
928
+ realm.call('__csim_swReportClient') rescue nil
840
929
  # A `javascript:` URL frame: the initial empty document is now loaded and
841
930
  # parent/top are wired, so evaluate the URL's script in the realm (global
842
931
  # scope). Per HTML, only a STRING result navigates the frame to a new
@@ -885,7 +974,7 @@ module Capybara
885
974
  # a native `__csimFrameWindowProxyFor`, so `popup.document` is a real
886
975
  # same-isolate Document (cross-window adoptNode works). The single isolate also
887
976
  # makes a same-origin window far cheaper than today's isolate-per-window.
888
- def create_window_realm(url, body, content_type, opener_id: nil, window_name: nil, doc_origin: nil, location_origin: nil)
977
+ def create_window_realm(url, body, content_type, opener_id: nil, window_name: nil, doc_origin: nil, location_origin: nil, about_base: nil, about_origin: nil)
889
978
  realm = seed_realm_bridge(ctx.create_context)
890
979
  # Mark it a top-level window realm so its location setter routes to
891
980
  # __csimWindowRealmNavigate (reload THIS realm) rather than the frame-nav or
@@ -917,7 +1006,21 @@ module Capybara
917
1006
  }
918
1007
  JS
919
1008
  end
920
- realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
1009
+ # `window.open()` with no URL opens about:blank — that IS the new document's URL, and a
1010
+ # realm built from the snapshot would otherwise keep reporting the OPENER's (making the
1011
+ # popup a phantom duplicate of its opener everywhere a document URL is read, the service-
1012
+ # worker client set included). Its ORIGIN and its BASE url are inherited from the opener,
1013
+ # exactly as an empty <iframe>'s are (create_frame_realm's frame_about_base): the origin
1014
+ # so the popup isn't cross-origin to the window that opened it, the base so relative
1015
+ # resolution inside it still targets the opener's document.
1016
+ if url.to_s.empty?
1017
+ realm.call('__csimUpdateLocation', 'about:blank')
1018
+ realm.call('__csimSetAboutBaseURL', about_base.to_s) unless about_base.to_s.empty?
1019
+ doc_origin ||= about_origin unless about_origin.to_s.empty?
1020
+ location_origin ||= about_origin unless about_origin.to_s.empty?
1021
+ else
1022
+ realm.call('__csimUpdateLocation', url.to_s)
1023
+ end
921
1024
  realm.call('__csimSetWindowName', window_name.to_s) unless window_name.nil?
922
1025
  realm.call('__csimSetDocumentOrigin', doc_origin.to_s) unless doc_origin.nil?
923
1026
  realm.call('__csimSetLocationOrigin', location_origin.to_s) unless location_origin.nil?
@@ -936,8 +1039,10 @@ module Capybara
936
1039
  # Remember the window's opener / name so a self-navigation (reload_window_realm
937
1040
  # builds a FRESH realm) can carry them across — a real popup keeps window.opener
938
1041
  # and window.name through its own navigation.
939
- window_realm_meta[realm.id] = {opener_id: opener_id, window_name: window_name}
1042
+ window_realm_meta[realm.id] = {opener_id: opener_id, window_name: window_name, about_base: about_base, about_origin: about_origin}
940
1043
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
1044
+ # As in create_frame_realm: an auxiliary window is a client of its origin too.
1045
+ realm.call('__csim_swReportClient') rescue nil
941
1046
  realm.call('__csimFireWindowLoad') rescue nil
942
1047
  realm.id
943
1048
  rescue StandardError => e
@@ -1308,10 +1413,29 @@ module Capybara
1308
1413
  # the per-worker postMessage host fn closed over `post_back`.
1309
1414
  # Returns a uniform `WorkerRuntime` adapter that
1310
1415
  # `Browser#run_worker` drives.
1311
- def self.build_worker(browser, post_back)
1416
+ def self.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {})
1312
1417
  c = Ctx.new(snapshot: snapshot)
1313
1418
  attach_host_fns(c, browser)
1314
1419
  c.attach('__csim_workerPostMessage', ->(data) { post_back.call(data); nil })
1420
+ # Service-worker → main-thread signals route through the thread-safe outbox (delivered by
1421
+ # deliver_worker_messages): client.postMessage, clients.claim (set the client's controller),
1422
+ # and a controlled fetch's respondWith result. See run_worker for the closures.
1423
+ c.attach('__csim_swPostToClient', ->(client_id, data) { sw_hooks[:post_to_client]&.call(client_id, data); nil }) if sw_hooks[:post_to_client]
1424
+ c.attach('__csim_swFocusClient', ->(client_id) { sw_hooks[:focus_client]&.call(client_id); nil }) if sw_hooks[:focus_client]
1425
+ c.attach('__csim_swNavigateClient', ->(client_id, url, nav_id) { sw_hooks[:navigate_client]&.call(client_id, url, nav_id); nil }) if sw_hooks[:navigate_client]
1426
+ c.attach('__csim_swClaim', -> { sw_hooks[:claim]&.call; nil }) if sw_hooks[:claim]
1427
+ c.attach('__csim_swSkipWaitingRequest', -> { sw_hooks[:skip_waiting]&.call; nil }) if sw_hooks[:skip_waiting]
1428
+ c.attach('__csim_swFetchRespond', ->(fetch_id, resp, realm_id) { sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil }) if sw_hooks[:fetch_respond]
1429
+ c.attach('__csim_swFetchStream', ->(fetch_id, kind, payload, realm_id) { sw_hooks[:fetch_stream]&.call(fetch_id, kind, payload, realm_id); nil }) if sw_hooks[:fetch_stream]
1430
+ # Cross-isolate MessagePort channel signals (a worker/SW port endpoint + its outbound messages).
1431
+ c.attach('__csim_workerPortPost', ->(channel, data) { sw_hooks[:port_post]&.call(channel, data); nil }) if sw_hooks[:port_post]
1432
+ c.attach('__csim_workerPortEndpoint', ->(channel) { sw_hooks[:port_endpoint]&.call(channel); nil }) if sw_hooks[:port_endpoint]
1433
+ # A worker is a SEPARATE isolate: its BroadcastChannel fan-out to the main + frame realms +
1434
+ # other workers can't run `browser.broadcast_to_windows` inline (that would mutate the main
1435
+ # browser's inbox from the worker thread). Route it through the thread-safe outbox instead
1436
+ # (the shared BROWSER_HOST_FNS `__csimBroadcast` above, bound to `browser`, is overridden
1437
+ # here). Same-worker-context delivery already happened in-VM via `_bcChannels`.
1438
+ c.attach('__csimBroadcast', ->(name, data, _rid, origin) { broadcast_out&.call(name, data, origin); nil }) if broadcast_out
1315
1439
  # Worker's timer table is independent from main's; routing the
1316
1440
  # worker's `setTimersActive` through `browser.timers_active=`
1317
1441
  # races the main isolate's polling? gate, dropping main-thread
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.7.0'
5
+ VERSION = '0.9.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-simulated
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
@@ -112,7 +112,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
112
  - !ruby/object:Gem::Version
113
113
  version: '0'
114
114
  requirements: []
115
- rubygems_version: 4.0.10
115
+ rubygems_version: 4.0.16
116
116
  specification_version: 4
117
117
  summary: Lightweight Capybara driver with an in-process JS-resident DOM, Chrome-free
118
118
  test_files: []