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.
- checksums.yaml +4 -4
- data/README.md +37 -5
- data/exe/capybara-simulated +143 -0
- data/lib/capybara/simulated/browser.rb +570 -53
- data/lib/capybara/simulated/driver.rb +52 -6
- data/lib/capybara/simulated/js/bridge.bundle.js +11575 -5716
- 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 +9 -0
- data/lib/capybara/simulated/rspec.rb +32 -0
- data/lib/capybara/simulated/runtime_shared.rb +26 -2
- 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 +182 -17
- data/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/vendor.bundle.js +21 -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,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 << {
|
|
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
|