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.
- checksums.yaml +4 -4
- data/README.md +76 -204
- data/exe/capybara-simulated +143 -0
- data/lib/capybara/simulated/browser.rb +1488 -153
- data/lib/capybara/simulated/driver.rb +260 -11
- data/lib/capybara/simulated/js/bridge.bundle.js +20801 -10479
- data/lib/capybara/simulated/js/snapshot_stubs.js +34 -0
- data/lib/capybara/simulated/minitest.rb +65 -0
- data/lib/capybara/simulated/quickjs_runtime.rb +26 -6
- data/lib/capybara/simulated/rspec.rb +32 -0
- data/lib/capybara/simulated/runtime_shared.rb +57 -6
- data/lib/capybara/simulated/trace.rb +35 -2
- data/lib/capybara/simulated/trace_persistence.rb +48 -0
- data/lib/capybara/simulated/trace_viewer.html +408 -0
- data/lib/capybara/simulated/v8_runtime.rb +317 -27
- data/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/vendor.bundle.js +24 -10
- metadata +23 -3
|
@@ -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
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
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:
|
|
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).
|
|
75
|
-
# window
|
|
76
|
-
# the
|
|
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
|
-
'
|
|
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 << {
|
|
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
|