capybara-simulated 0.4.0 → 0.5.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,6 +216,15 @@ module Capybara
216
216
  # already the inter-test reset point.
217
217
  def reset_page = rebuild_ctx
218
218
 
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
+
219
228
  # bridge.js patches `Intl.DateTimeFormat`; rusty_racer ships ICU
220
229
  # built-in but QuickJS gates it behind a polyfill flag. Other JS
221
230
  # surfaces bridge.js touches (URL / TextEncoder / atob/btoa /
@@ -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,18 @@ 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 nested browsing context reloading its OWN location (a[0] = the frame's
53
+ # realm id). Deferred + applied by re-navigating the owning iframe to its
54
+ # current document — see Browser#frame_reload_self.
55
+ '__csimFrameReload' => ->(b, *a) { b.frame_reload_self(a[0].to_i); nil },
56
+ # A <form> submitted from inside a nested browsing context (a[0] = the
57
+ # initiating frame's realm id). Deferred + applied against that realm,
58
+ # like __csimFrameNavigate — see Browser#frame_submit_self.
59
+ '__csimFrameSubmit' => ->(b, *a) { b.frame_submit_self(a[0].to_i); nil },
48
60
  '__setTimersActive' => ->(b, *a) { b.timers_active = !!a[0]; nil },
49
61
  '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1]); nil },
50
62
  '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1]); nil },
@@ -76,18 +88,30 @@ module Capybara
76
88
  # the target window's Browser.
77
89
  '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1]) },
78
90
  '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
91
+ '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1]); nil },
92
+ '__csimWindowGet' => ->(b, *a) { b.window_get(a[0], a[1]) },
93
+ '__csimWindowDocGet' => ->(b, *a) { b.window_doc_get(a[0], a[1]) },
79
94
  '__csimWindowLocation' => ->(b, *a) { b.window_location_of(a[0]) },
80
95
  '__csimWindowSetLocation' => ->(b, *a) { b.set_window_location(a[0], a[1]); nil },
81
96
  '__csimWindowClosed' => ->(b, *a) { b.window_closed?(a[0]) },
82
97
  '__csimWindowClose' => ->(b, *a) { b.close_child_window(a[0]); nil },
83
98
  '__csimWindowOpener' => ->(b, *_) { b.opener_handle },
84
- '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0]) },
99
+ '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1]) },
85
100
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
86
101
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
87
102
  '__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 },
103
+ '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
104
+ # WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
105
+ # delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
106
+ '__csim_domainToASCII' => ->(b, *a) { b.domain_to_ascii(a[0]) },
107
+ '__csim_domainToUnicode' => ->(b, *a) { b.domain_to_unicode(a[0]) },
89
108
  '__csim_blobResolve' => ->(b, *a) { b.blob_resolve(a[0]) },
90
109
  '__csim_blobUnregister' => ->(b, *a) { b.blob_unregister(a[0]); nil },
110
+ # Any non-timer async channel (worker / SSE / hijacked fetch / window
111
+ # message / websocket) still in flight — the WPT runner's drain consults
112
+ # this so it doesn't bail before an async message (e.g. a freshly-spawned
113
+ # worker's first postMessage) has had a chance to land.
114
+ '__csim_asyncIoPending' => ->(b, *_a) { b.async_io_pending? },
91
115
  '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
92
116
  '__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
93
117
  # Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
@@ -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