capybara-simulated 0.4.0 → 0.6.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.
@@ -56,6 +56,40 @@ if (typeof globalThis.TextDecoder === 'undefined') {
56
56
  }
57
57
  };
58
58
  }
59
+ // parse5 unpacks its base64-packed named-character-reference table with `atob`
60
+ // AT MODULE-LOAD = snapshot-BUILD time here, and the V8 snapshot context has no
61
+ // `atob`. Provide a correct RFC 4648 base64 decoder (+ encoder) before the
62
+ // vendor bundle so parse5's table bakes correctly. The bridge's encoding.js
63
+ // later overrides `globalThis.atob`/`btoa` for app code; parse5 keeps only the
64
+ // decoded table (no reference to atob), so this stub only matters at build time.
65
+ if (typeof globalThis.atob === 'undefined') {
66
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
67
+ globalThis.atob = function (input) {
68
+ const s = String(input).replace(/[ \t\n\f\r=]/g, '');
69
+ let out = '', acc = 0, bits = 0;
70
+ for (let i = 0; i < s.length; i++) {
71
+ const v = B64.indexOf(s[i]);
72
+ if (v < 0) continue;
73
+ acc = (acc << 6) | v; bits += 6;
74
+ if (bits >= 8) { bits -= 8; out += String.fromCharCode((acc >> bits) & 0xff); }
75
+ }
76
+ return out;
77
+ };
78
+ globalThis.btoa = function (input) {
79
+ const s = String(input); let out = '';
80
+ for (let i = 0; i < s.length; i += 3) {
81
+ const a = s.charCodeAt(i);
82
+ const b = i + 1 < s.length ? s.charCodeAt(i + 1) : NaN;
83
+ const c = i + 2 < s.length ? s.charCodeAt(i + 2) : NaN;
84
+ const e1 = a >> 2;
85
+ const e2 = ((a & 3) << 4) | (isNaN(b) ? 0 : b >> 4);
86
+ const e3 = isNaN(b) ? 64 : (((b & 15) << 2) | (isNaN(c) ? 0 : c >> 6));
87
+ const e4 = isNaN(c) ? 64 : c & 63;
88
+ out += B64[e1] + B64[e2] + (e3 === 64 ? '=' : B64[e3]) + (e4 === 64 ? '=' : B64[e4]);
89
+ }
90
+ return out;
91
+ };
92
+ }
59
93
  globalThis.__csim_parseUrl = function (input, base) {
60
94
  return {
61
95
  href: 'http://placeholder/', protocol: 'http:',
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest'
4
+ require_relative 'trace_persistence'
5
+
6
+ # Minitest integration for trace file output. Require it from your
7
+ # `test_helper` / `application_system_test_case.rb`:
8
+ #
9
+ # require 'capybara/simulated/minitest'
10
+ #
11
+ # With `CSIM_TRACE_DIR=/path/to/dir` set, each test that recorded a trace
12
+ # is persisted to `<dir>/<Class_test_name>.json` after it runs. Inert
13
+ # when the env var is unset. Whether a trace is recorded at all is
14
+ # governed separately by `CSIM_TRACE` (off / on-failure / full) — see
15
+ # Browser. Render a saved trace into an HTML viewer with
16
+ # `capybara-simulated trace <file>.json`.
17
+ module Capybara
18
+ module Simulated
19
+ module MinitestTrace
20
+ module_function
21
+
22
+ # Where the test method is defined, as `path:line` (best effort).
23
+ def source_file(test)
24
+ loc = test.class.instance_method(test.name).source_location
25
+ loc && loc.join(':')
26
+ rescue NameError
27
+ nil
28
+ end
29
+
30
+ # Skips aren't failures for outcome purposes. `::Minitest` —
31
+ # unqualified `Minitest` here resolves to `Capybara::Minitest`
32
+ # (Capybara ships that submodule), which has no `Skip`.
33
+ def real_failures(test)
34
+ test.failures.reject {|f| f.is_a?(::Minitest::Skip) }
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ if (dir = ENV['CSIM_TRACE_DIR']) && !dir.empty?
41
+ FileUtils.mkdir_p(dir)
42
+ # Prepend so `after_teardown` chains via `super` and we run after the
43
+ # host's own teardown. Guarded by `tracing?` inside persist_all, so it
44
+ # no-ops for every non-Capybara test.
45
+ hook = Module.new do
46
+ define_method(:after_teardown) do
47
+ super()
48
+ ensure
49
+ begin
50
+ fails = Capybara::Simulated::MinitestTrace.real_failures(self)
51
+ Capybara::Simulated::TracePersistence.persist_all(
52
+ dir,
53
+ title: "#{self.class}##{name}",
54
+ file: Capybara::Simulated::MinitestTrace.source_file(self),
55
+ outcome: fails.empty? ? 'passed' : 'failed',
56
+ exception: fails.first&.message
57
+ )
58
+ rescue StandardError => e
59
+ # Trace output must never turn a test red.
60
+ warn "capybara-simulated: trace persist failed: #{e.message}"
61
+ end
62
+ end
63
+ end
64
+ Minitest::Test.prepend(hook)
65
+ end
@@ -216,11 +216,28 @@ module Capybara
216
216
  # already the inter-test reset point.
217
217
  def reset_page = rebuild_ctx
218
218
 
219
- # bridge.js patches `Intl.DateTimeFormat`; rusty_racer ships ICU
220
- # built-in but QuickJS gates it behind a polyfill flag. Other JS
221
- # surfaces bridge.js touches (URL / TextEncoder / atob/btoa /
222
- # crypto) are already routed through Ruby-side host fns, so
223
- # POLYFILL_INTL is the only one we strictly need.
219
+ # NOTE: intentionally NO `dispose` Browser#dispose gates its
220
+ # `@runtime.dispose` call on `respond_to?(:dispose)`, so QuickJS skips it.
221
+ # QuickJS VMs aren't pinned by a process-wide registry the way V8 isolates
222
+ # are in V8Runtime's `@@live`; an aux window's Browser becomes unreferenced
223
+ # on close and Ruby GC's dfree frees its `@vm` (the same lifecycle
224
+ # `rebuild_ctx` already relies on). Adding a `@vm = nil` dispose would only
225
+ # introduce a NoMethodError window for any stray post-close call (eval/call
226
+ # don't all nil-guard `@vm`) with no leak benefit.
227
+
228
+ # bridge.js patches `Intl.DateTimeFormat`; rusty_racer ships ICU built-in but
229
+ # QuickJS gates it behind a polyfill flag (other surfaces bridge.js touches —
230
+ # URL / TextEncoder / atob/btoa / crypto — already route through Ruby host fns,
231
+ # so POLYFILL_INTL is the only one we strictly need).
232
+ #
233
+ # PERF (rule 3): quickjs is pinned to `~> 0.18.0` in the Gemfile. quickjs 0.19
234
+ # both split the Intl polyfills into a separate quickjs-polyfill-intl gem (which
235
+ # eval's them per VM at ~226 ms — vs this single bundled flag's ~140 ms) AND
236
+ # regressed interpreter execution ~2.8× (measured: the QuickJS spec suite ran
237
+ # 5.6 min on 0.18 vs 15.5 min on 0.19 with an equivalent Intl set). The 0.19
238
+ # migration is recorded in the cross-window / quickjs-CI memory; re-migrate to
239
+ # 0.19 + quickjs-polyfill-intl once that upstream perf regression is fixed.
240
+ INTL_FEATURES = [Quickjs::POLYFILL_INTL].freeze
224
241
  #
225
242
  # `max_stack_size: 0` — `JS_SetMaxStackSize` measures C stack
226
243
  # delta from runtime construction; Ruby callers reach QuickJS
@@ -236,7 +253,7 @@ module Capybara
236
253
  # handler returns `elapsed >= limit_ms`, so 0 fires on the first
237
254
  # check), so practical no-limit.
238
255
  VM_OPTIONS = {
239
- features: [Quickjs::POLYFILL_INTL].freeze,
256
+ features: INTL_FEATURES,
240
257
  max_stack_size: 0,
241
258
  # quickjs.rb's 128 MB default trips "out of memory in regexp
242
259
  # execution" on class-attribute-heavy polls and the heaviest
@@ -344,6 +361,9 @@ module Capybara
344
361
  # flip doesn't race main's `polling?` gate. See v8_runtime's
345
362
  # build_worker for the long-form rationale.
346
363
  vm.define_function('__setTimersActive') {|_flag| nil }
364
+ # importScripts runs a classic script at top-level scope so its top-level
365
+ # const/let/class share the realm's global lexical env (see v8_runtime).
366
+ vm.define_function('__csim_workerImportEval') {|src| vm.eval_code(src.to_s); nil }
347
367
  vm.eval_code('__csim_installWorkerScope();')
348
368
  vm.drain_jobs!
349
369
  WorkerRuntime.new(
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+ require_relative 'trace_persistence'
5
+
6
+ # RSpec integration for trace file output. Require it from your
7
+ # `spec_helper` / `rails_helper`:
8
+ #
9
+ # require 'capybara/simulated/rspec'
10
+ #
11
+ # With `CSIM_TRACE_DIR=/path/to/dir` set, each example that recorded a
12
+ # trace is persisted to `<dir>/<example slug>.json` after it runs. Inert
13
+ # when the env var is unset. Whether a trace is recorded at all is
14
+ # governed separately by `CSIM_TRACE` (off / on-failure / full) — see
15
+ # Browser. Render a saved trace into an HTML viewer with
16
+ # `capybara-simulated trace <file>.json`.
17
+ if (dir = ENV['CSIM_TRACE_DIR']) && !dir.empty?
18
+ FileUtils.mkdir_p(dir)
19
+ RSpec.configure do |config|
20
+ # `prepend_after` so we capture the trace before a host's own
21
+ # teardown (e.g. Capybara session reset) runs.
22
+ config.prepend_after(:each) do |example|
23
+ Capybara::Simulated::TracePersistence.persist_all(
24
+ dir,
25
+ title: example.full_description,
26
+ file: example.location,
27
+ outcome: example.exception ? 'failed' : 'passed',
28
+ exception: example.exception&.message
29
+ )
30
+ end
31
+ end
32
+ end
@@ -45,6 +45,23 @@ module Capybara
45
45
  '__csimExternalAsset' => ->(b, *a) { b.external_asset_source(a[0]) },
46
46
  '__locationAssign' => ->(b, *a) { b.location_assign(a[0]); nil },
47
47
  '__locationReload' => ->(b, *_) { b.location_reload; nil },
48
+ # A nested browsing context navigating its OWN location (a[1] = the frame's
49
+ # realm id). Deferred + applied by re-navigating the owning iframe, so a
50
+ # frame's `location.href = …` navigates the frame, not the top page.
51
+ '__csimFrameNavigate' => ->(b, *a) { b.frame_navigate_self(a[0], a[1].to_i); nil },
52
+ # A same-origin window realm (window.open in this isolate) navigating itself
53
+ # (a[0] = url, a[1] = the window's realm id). Deferred + applied by reloading
54
+ # that realm's document in place — see Browser#window_realm_navigate_self.
55
+ '__csimWindowRealmNavigate' => ->(b, *a) { b.window_realm_navigate_self(a[0], a[1].to_i); nil },
56
+ # A nested browsing context reloading its OWN location (a[0] = the frame's
57
+ # realm id). Deferred + applied by re-navigating the owning iframe to its
58
+ # current document — see Browser#frame_reload_self.
59
+ '__csimFrameReload' => ->(b, *a) { b.frame_reload_self(a[0].to_i); nil },
60
+ # A <form> submitted from inside a nested browsing context (a[0] = the
61
+ # initiating frame's realm id). Deferred + applied against that realm,
62
+ # like __csimFrameNavigate — see Browser#frame_submit_self.
63
+ '__csimFrameSubmit' => ->(b, *a) { b.frame_submit_self(a[0].to_i); nil },
64
+ '__csimFrameHistoryGo' => ->(b, *a) { b.frame_history_go(a[0].to_i, a[1].to_i); nil },
48
65
  '__setTimersActive' => ->(b, *a) { b.timers_active = !!a[0]; nil },
49
66
  '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1]); nil },
50
67
  '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1]); nil },
@@ -71,27 +88,61 @@ module Capybara
71
88
  '__csim_wsClose' => ->(b, *a) { b.ws_close(a[0], a[1], a[2]); nil },
72
89
  '__csim_rackFetchAsync' => ->(b, *a) { b.rack_fetch_async(a[0], a[1], a[2], a[3]) },
73
90
  '__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
74
- # Cross-window references (window.open / opener / postMessage). Each
75
- # window is a separate VM, so these forward to the Driver to route to
76
- # the target window's Browser.
77
- '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1]) },
91
+ # Cross-window references (window.open / opener / postMessage). A separate-VM
92
+ # aux window forwards to the Driver; a same-origin window realm lives in this
93
+ # isolate. a[2] is the opener's realm id (for wiring window.opener).
94
+ '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1], a[2]) },
95
+ # A `target=_blank`/named link/area activation from a frame or window realm:
96
+ # open a new auxiliary window (the realm's VM isn't rebuilt — a fresh window
97
+ # is). `opener` = rel=opener (target=_blank defaults to noopener); the Driver
98
+ # forces noopener for a cross-partition blob: target.
99
+ '__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
78
100
  '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
101
+ '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i); nil },
102
+ '__csimWindowGet' => ->(b, *a) { b.window_get(a[0], a[1]) },
103
+ '__csimWindowDocGet' => ->(b, *a) { b.window_doc_get(a[0], a[1]) },
104
+ # Cross-window remote-ref RPC: route an opener's node/object proxy op to
105
+ # the target window's VM (a[0]=window handle, a[1]=ref id, a[2]=prop/method).
106
+ '__csimWindowRefGet' => ->(b, *a) { b.window_ref_get(a[0], a[1], a[2]) },
107
+ '__csimWindowRefSet' => ->(b, *a) { b.window_ref_set(a[0], a[1], a[2], a[3]); nil },
108
+ '__csimWindowRefCall' => ->(b, *a) { b.window_ref_call(a[0], a[1], a[2], a[3]) },
79
109
  '__csimWindowLocation' => ->(b, *a) { b.window_location_of(a[0]) },
80
110
  '__csimWindowSetLocation' => ->(b, *a) { b.set_window_location(a[0], a[1]); nil },
111
+ '__csimWindowHistoryGo' => ->(b, *a) { b.window_history_go(a[0], a[1]) },
81
112
  '__csimWindowClosed' => ->(b, *a) { b.window_closed?(a[0]) },
82
113
  '__csimWindowClose' => ->(b, *a) { b.close_child_window(a[0]); nil },
83
114
  '__csimWindowOpener' => ->(b, *_) { b.opener_handle },
84
- '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0]) },
115
+ # Fire an aux window's OWN `load` event (in its VM) — deferred by the
116
+ # opener so a child `window.onload` runs after the opener's current task.
117
+ '__csimFireAuxWindowLoad' => ->(b, *a) { b.fire_aux_window_load(a[0]); nil },
118
+ '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1]) },
119
+ # navigator.serviceWorker.register (universal-server only) — spawn a worker
120
+ # running the SW script as an executor context. Returns its handle.
121
+ '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true) },
85
122
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
86
123
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
87
124
  '__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
88
- '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1]); nil },
125
+ '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
126
+ # WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
127
+ # delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
128
+ '__csim_domainToASCII' => ->(b, *a) { b.domain_to_ascii(a[0]) },
129
+ '__csim_domainToUnicode' => ->(b, *a) { b.domain_to_unicode(a[0]) },
89
130
  '__csim_blobResolve' => ->(b, *a) { b.blob_resolve(a[0]) },
90
131
  '__csim_blobUnregister' => ->(b, *a) { b.blob_unregister(a[0]); nil },
132
+ # Any non-timer async channel (worker / SSE / hijacked fetch / window
133
+ # message / websocket) still in flight — the WPT runner's drain consults
134
+ # this so it doesn't bail before an async message (e.g. a freshly-spawned
135
+ # worker's first postMessage) has had a chance to land.
136
+ '__csim_asyncIoPending' => ->(b, *_a) { b.async_io_pending? },
91
137
  '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
92
138
  '__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
93
139
  # Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
94
140
  '__csim_transferIssued' => ->(b, *a) { b.transfer_token_issued(a[0]); nil },
141
+ # Universal-server context (WPT runner)? Gates cross-origin eager frame
142
+ # building: only there is a cross-origin iframe's content served locally,
143
+ # so an ordinary app leaves cross-origin frames lazy (= baseline) and never
144
+ # eager-@app.calls a foreign URL (side effects: extra visit / log row).
145
+ '__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
95
146
  '__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
96
147
  '__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
97
148
  # WebAuthn create / get raise `WebauthnState::Error` carrying
@@ -29,6 +29,24 @@ module Capybara
29
29
  keyword_init: true
30
30
  )
31
31
 
32
+ VIEWER_TEMPLATE_PATH = File.expand_path('trace_viewer.html', __dir__)
33
+ VIEWER_DATA_TOKEN = '__CSIM_TRACE_DATA__'
34
+
35
+ # Render the self-contained HTML viewer for a trace JSON *string*,
36
+ # embedding it inline (the `capybara-simulated trace` CLI is the
37
+ # caller). `</` → `<\/` so an embedded `</script>` inside a DOM
38
+ # snapshot can't close the data block early — still valid JSON
39
+ # (`\/` is a legal JSON escape for `/`). The whole point of inline
40
+ # embedding over fetch / `import … with { type: 'json' }` is that
41
+ # the result opens straight from `file://` with no server (module /
42
+ # fetch loads are CORS-blocked for `file://` origins).
43
+ def self.render_viewer(json_text)
44
+ template = (@viewer_template ||= File.read(VIEWER_TEMPLATE_PATH))
45
+ # Block form: the replacement is taken literally, so backslashes
46
+ # in the JSON aren't interpreted as regexp backreferences.
47
+ template.sub(VIEWER_DATA_TOKEN) { json_text.to_s.gsub('</', '<\/') }
48
+ end
49
+
32
50
  attr_reader :steps, :metadata
33
51
 
34
52
  def initialize(metadata: {})
@@ -49,9 +67,24 @@ module Capybara
49
67
  @console_buf << {severity: severity.to_s, message: message.to_s}
50
68
  end
51
69
 
52
- def log_network(method, url, status)
70
+ def log_network(method, url, status,
71
+ content_type: nil, size: nil, duration_ms: nil, redirected: nil,
72
+ request_headers: nil, request_body: nil,
73
+ response_headers: nil, response_body: nil)
53
74
  return unless @open_step
54
- @network_buf << {method: method.to_s, url: url.to_s, status: status}
75
+ @network_buf << {
76
+ method: method.to_s,
77
+ url: url.to_s,
78
+ status: status,
79
+ content_type: content_type,
80
+ size: size,
81
+ duration_ms: duration_ms,
82
+ redirected: redirected,
83
+ request_headers: request_headers,
84
+ request_body: request_body,
85
+ response_headers: response_headers,
86
+ response_body: response_body
87
+ }.compact # drop fields the caller couldn't determine, keeping entries lean
55
88
  end
56
89
 
57
90
  def begin_step(kind, description:, url_before: nil)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'capybara/simulated'
5
+
6
+ module Capybara
7
+ module Simulated
8
+ # Framework-agnostic trace file-output, shared by the RSpec
9
+ # (`capybara/simulated/rspec`) and Minitest
10
+ # (`capybara/simulated/minitest`) integrations. Each of those reads
11
+ # `CSIM_TRACE_DIR` and feeds the per-example outcome here; this just
12
+ # stamps metadata and writes `<slug>.json`.
13
+ module TracePersistence
14
+ module_function
15
+
16
+ # Filename-safe slug: keep word-ish chars, collapse the rest to one
17
+ # `_`, cap length so a long description can't blow the path limit.
18
+ def slug(name)
19
+ name.to_s.gsub(/[^A-Za-z0-9._-]+/, '_')[0, 200]
20
+ end
21
+
22
+ # Stamp the outcome onto one driver's trace and write it. No-op
23
+ # unless the driver actually recorded something.
24
+ def persist(driver, dir, title:, file:, outcome:, exception:)
25
+ return unless driver.respond_to?(:tracing?) && driver.tracing?
26
+ driver.current_trace.metadata.merge!(
27
+ title: title,
28
+ file: file,
29
+ outcome: outcome,
30
+ exception: exception
31
+ )
32
+ driver.stop_tracing(path: File.join(dir, "#{slug(title)}.json"))
33
+ end
34
+
35
+ # Persist every tracing simulated driver on the current thread
36
+ # (one example normally has exactly one). Trace output must never
37
+ # change a test's result, so a write failure is warned and
38
+ # swallowed rather than propagated out of the after-hook.
39
+ def persist_all(dir, **fields)
40
+ Capybara::Simulated::Driver.each_live_on_thread(Thread.current) do |driver|
41
+ persist(driver, dir, **fields)
42
+ rescue StandardError => e
43
+ warn "capybara-simulated: failed to write trace: #{e.message}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end