capybara-simulated 0.0.7 → 0.1.1
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 +303 -158
- data/lib/capybara/simulated/asset_cache.rb +232 -0
- data/lib/capybara/simulated/browser.rb +3409 -845
- data/lib/capybara/simulated/driver.rb +341 -134
- data/lib/capybara/simulated/errors.rb +9 -5
- data/lib/capybara/simulated/js/bridge.bundle.js +19738 -0
- data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
- data/lib/capybara/simulated/node.rb +151 -163
- data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
- data/lib/capybara/simulated/runtime_shared.rb +183 -0
- data/lib/capybara/simulated/script_cache.rb +168 -0
- data/lib/capybara/simulated/sourcemap.rb +119 -0
- data/lib/capybara/simulated/stack_resolver.rb +97 -0
- data/lib/capybara/simulated/trace.rb +111 -0
- data/lib/capybara/simulated/v8_runtime.rb +987 -0
- data/lib/capybara/simulated/version.rb +3 -1
- data/lib/capybara/simulated/webauthn_state.rb +367 -0
- data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
- data/lib/capybara/simulated/worker_runtime.rb +30 -0
- data/lib/capybara/simulated.rb +31 -4
- data/lib/capybara-simulated.rb +2 -0
- data/vendor/js/vendor.bundle.js +13 -0
- metadata +24 -32
- data/vendor/esbuild-wasm/LICENSE.md +0 -21
- data/vendor/esbuild-wasm/bin/esbuild +0 -91
- data/vendor/esbuild-wasm/esbuild.wasm +0 -0
- data/vendor/esbuild-wasm/lib/main.js +0 -2337
- data/vendor/esbuild-wasm/wasm_exec.js +0 -575
- data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
- data/vendor/js/bundle-modules.mjs +0 -168
- data/vendor/js/csim.bundle.js +0 -91560
- data/vendor/js/entry.mjs +0 -23
- data/vendor/js/prelude.js +0 -190
- data/vendor/js/runtime.js +0 -2208
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Capybara
|
|
7
|
+
module Simulated
|
|
8
|
+
# Process-wide + on-disk cache of V8 `ScriptCompiler::CachedData`
|
|
9
|
+
# blobs keyed by `(version_tag, sha256(body))`. V8Runtime feeds
|
|
10
|
+
# every `__csim_runScript` body through here so the second visit
|
|
11
|
+
# (and every subsequent process) skips the parse + JIT step on
|
|
12
|
+
# large app bundles — Discourse's main chunk goes from ~140 ms of
|
|
13
|
+
# recompile per visit to a deserialize + run path.
|
|
14
|
+
#
|
|
15
|
+
# Cross-process portability requires snapshot-bytes equality:
|
|
16
|
+
# `RustyRacer::Snapshot.new(source)` is non-deterministic, so the
|
|
17
|
+
# blobs we produce here are only consumable by another process if
|
|
18
|
+
# that process loads the SAME snapshot bytes via `Snapshot.load`.
|
|
19
|
+
# `V8Runtime.build_snapshot` handles that side.
|
|
20
|
+
#
|
|
21
|
+
# **Produce path stays at top level.** `Context#compile(produce_
|
|
22
|
+
# cache: true)` is unsafe inside a host-fn callback (V8's
|
|
23
|
+
# `CreateCodeCache` corrupts the parser when re-entered — rusty_racer
|
|
24
|
+
# raises immediately). The host-fn consumer queues misses;
|
|
25
|
+
# `V8Runtime#call` drains the queue from top-level Ruby after each
|
|
26
|
+
# bridge call returns.
|
|
27
|
+
module ScriptCache
|
|
28
|
+
DEFAULT_DIR = File.join(ENV['HOME'] || '/tmp', '.cache', 'capybara-simulated', 'script-cache')
|
|
29
|
+
|
|
30
|
+
@lock = Mutex.new
|
|
31
|
+
@mem = {}
|
|
32
|
+
@pending = {}
|
|
33
|
+
@writer_q = nil
|
|
34
|
+
@writer_t = nil
|
|
35
|
+
|
|
36
|
+
at_exit { ScriptCache.flush! }
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def dir = ENV['CSIM_SCRIPT_CACHE_DIR'] || DEFAULT_DIR
|
|
40
|
+
# `V8Runtime#call` drains `warm_pending!` after EVERY host-fn call (finds,
|
|
41
|
+
# polls, dispatch), so this is one of the hottest Ruby methods — memoize
|
|
42
|
+
# the env decision instead of re-reading ENV + allocating a string per
|
|
43
|
+
# call (the var is fixed per process).
|
|
44
|
+
def enabled?
|
|
45
|
+
return @enabled unless @enabled.nil?
|
|
46
|
+
@enabled = !ENV['CSIM_SCRIPT_CACHE'].to_s.casecmp('off').zero?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def lookup(sha, version_tag, kind: :script)
|
|
50
|
+
return nil unless enabled?
|
|
51
|
+
key = cache_key(sha, version_tag, kind)
|
|
52
|
+
mem_hit = @lock.synchronize { @mem[key] }
|
|
53
|
+
return mem_hit if mem_hit
|
|
54
|
+
blob = File.binread(disk_path_for(sha, version_tag, kind)) rescue nil
|
|
55
|
+
return nil unless blob
|
|
56
|
+
@lock.synchronize { @mem[key] ||= blob }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def store(sha, version_tag, blob, kind: :script)
|
|
60
|
+
return unless enabled? && blob
|
|
61
|
+
key = cache_key(sha, version_tag, kind)
|
|
62
|
+
@lock.synchronize { @mem[key] = blob }
|
|
63
|
+
enqueue_disk_write(disk_path_for(sha, version_tag, kind), blob)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `stale: true` = the caller HAD a blob and V8 rejected it (the
|
|
67
|
+
# snapshot bytes changed under the same version tag — a bridge edit
|
|
68
|
+
# rebuilds the snapshot, and `Snapshot.new` is non-deterministic).
|
|
69
|
+
# The rejected blob must be evicted from `@mem`, or this guard would
|
|
70
|
+
# treat it as "already cached" and skip the re-produce forever:
|
|
71
|
+
# every visit then hits the stale blob, rejects, and full-parses —
|
|
72
|
+
# a silent, permanent loss of the bytecode cache (caught 2026-06-12:
|
|
73
|
+
# 113/113 rejects on the Discourse perf sample).
|
|
74
|
+
def queue_warm(ctx, sha, label, body, version_tag, kind: :script, stale: false)
|
|
75
|
+
return unless enabled?
|
|
76
|
+
key = cache_key(sha, version_tag, kind)
|
|
77
|
+
@lock.synchronize {
|
|
78
|
+
@mem.delete(key) if stale
|
|
79
|
+
return if @mem.key?(key) || @pending.key?(key)
|
|
80
|
+
@pending[key] = {ctx: ctx, label: label, body: body, version_tag: version_tag, sha: sha, kind: kind}
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def warm_pending!
|
|
85
|
+
# Fast path: `V8Runtime#call` drains here after every host-fn
|
|
86
|
+
# call (including hot polling like `__settleGenGet` /
|
|
87
|
+
# `__hasReadyTimer`), so the empty-queue branch must not pay
|
|
88
|
+
# the mutex round-trip. `@pending.empty?` lock-free read is
|
|
89
|
+
# safe — at worst a queued miss waits one extra `call`.
|
|
90
|
+
return unless enabled? && !@pending.empty?
|
|
91
|
+
pending = @lock.synchronize { p = @pending; @pending = {}; p }
|
|
92
|
+
return if pending.empty?
|
|
93
|
+
pending.each_value {|job|
|
|
94
|
+
ctx = job[:ctx]
|
|
95
|
+
next unless ctx.respond_to?(:compile)
|
|
96
|
+
begin
|
|
97
|
+
handle = if job[:kind] == :module
|
|
98
|
+
ctx.compile_module(job[:body], filename: job[:label].to_s, produce_cache: true)
|
|
99
|
+
else
|
|
100
|
+
ctx.compile(job[:body], filename: job[:label].to_s, produce_cache: true)
|
|
101
|
+
end
|
|
102
|
+
blob = handle.cached_data
|
|
103
|
+
handle.dispose
|
|
104
|
+
store(job[:sha], job[:version_tag], blob, kind: job[:kind]) if blob
|
|
105
|
+
rescue StandardError
|
|
106
|
+
# Best effort: a body that fails to produce a cache
|
|
107
|
+
# just keeps doing a parse-from-source next encounter.
|
|
108
|
+
end
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def flush!
|
|
113
|
+
q = @lock.synchronize { @writer_q }
|
|
114
|
+
return unless q
|
|
115
|
+
done = Queue.new
|
|
116
|
+
q.push([:sync, done])
|
|
117
|
+
done.pop
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def cache_key(sha, version_tag, kind = :script)
|
|
123
|
+
prefix = kind == :module ? 'm:' : ''
|
|
124
|
+
"#{version_tag}/#{prefix}#{sha}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def disk_path_for(sha, version_tag, kind = :script)
|
|
128
|
+
prefix = kind == :module ? 'm-' : ''
|
|
129
|
+
File.join(dir, version_tag.to_s, "#{prefix}#{sha}.bin")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Single background writer serialises disk I/O so the host-fn
|
|
133
|
+
# callback returns immediately. Parallel-worker writes to the
|
|
134
|
+
# same path are made safe by the tempfile+rename pattern.
|
|
135
|
+
def enqueue_disk_write(path, blob)
|
|
136
|
+
q = @lock.synchronize {
|
|
137
|
+
unless @writer_q
|
|
138
|
+
@writer_q = Queue.new
|
|
139
|
+
@writer_t = Thread.new { writer_loop(@writer_q) }
|
|
140
|
+
@writer_t.name = 'csim-script-cache-writer'
|
|
141
|
+
end
|
|
142
|
+
@writer_q
|
|
143
|
+
}
|
|
144
|
+
q.push([:write, path, blob])
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def writer_loop(q)
|
|
148
|
+
while (job = q.pop)
|
|
149
|
+
case job[0]
|
|
150
|
+
when :write
|
|
151
|
+
_, path, blob = job
|
|
152
|
+
write_atomically(path, blob) rescue nil
|
|
153
|
+
when :sync
|
|
154
|
+
job[1].push(:done)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def write_atomically(path, blob)
|
|
160
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
161
|
+
tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
162
|
+
File.binwrite(tmp, blob)
|
|
163
|
+
File.rename(tmp, path)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Capybara
|
|
6
|
+
module Simulated
|
|
7
|
+
# Minimal source-map v3 decoder. Just enough for the stack-trace
|
|
8
|
+
# rewriter to map `<asset URL>:<line>:<col>` back to
|
|
9
|
+
# `<source file>:<line>:<col>` so failing-test traces point at the
|
|
10
|
+
# original TS/JS instead of a 100k-char minified blob.
|
|
11
|
+
#
|
|
12
|
+
# Not handled:
|
|
13
|
+
# - `sections` (composite maps) — Vite/Rolldown don't emit them
|
|
14
|
+
# - `names` lookup — we don't need them for source-only resolution
|
|
15
|
+
class Sourcemap
|
|
16
|
+
Position = Struct.new(:source, :line, :column, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
19
|
+
BASE64_LOOKUP = BASE64_CHARS.each_char.with_index.to_h.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(json)
|
|
22
|
+
@sources = (json['sources'] || []).map(&:to_s)
|
|
23
|
+
@source_root = json['sourceRoot'].to_s
|
|
24
|
+
@segments_by_line = parse_mappings(json['mappings'].to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the nearest mapping at-or-before (line, column). Line/col
|
|
28
|
+
# are 1-based to match V8's stack-trace convention.
|
|
29
|
+
def resolve(line, column)
|
|
30
|
+
return nil if line < 1
|
|
31
|
+
row = @segments_by_line[line - 1]
|
|
32
|
+
return nil unless row && !row.empty?
|
|
33
|
+
# Per-line row is a flat Integer array of stride 4: [gen_col,
|
|
34
|
+
# source_idx, src_line, src_col, ...]. `bsearch_index` finds the
|
|
35
|
+
# first segment whose gen_col is *strictly greater* than target;
|
|
36
|
+
# the segment before that is the match.
|
|
37
|
+
target = column - 1
|
|
38
|
+
idx = (0...(row.length / 4)).bsearch {|i| row[i * 4] > target }
|
|
39
|
+
match_i = (idx || row.length / 4) - 1
|
|
40
|
+
return nil if match_i < 0
|
|
41
|
+
base = match_i * 4
|
|
42
|
+
source_idx = row[base + 1]
|
|
43
|
+
return nil unless source_idx && @sources[source_idx]
|
|
44
|
+
Position.new(
|
|
45
|
+
source: full_source(@sources[source_idx]),
|
|
46
|
+
line: row[base + 2] + 1,
|
|
47
|
+
column: row[base + 3] + 1
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def full_source(name)
|
|
54
|
+
return name if @source_root.empty?
|
|
55
|
+
return name if name.start_with?('/', 'http://', 'https://')
|
|
56
|
+
@source_root.end_with?('/') ? "#{@source_root}#{name}" : "#{@source_root}/#{name}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Decodes the `mappings` field into per-line flat Integer arrays.
|
|
60
|
+
# Each generated line's row is a stride-4 array:
|
|
61
|
+
# `[gen_col_0, source_idx_0, src_line_0, src_col_0, gen_col_1, ...]`.
|
|
62
|
+
# Flat layout cuts allocation count ~4× vs an array of 4-tuples,
|
|
63
|
+
# which matters for Vite bundles with 100k+ segments.
|
|
64
|
+
#
|
|
65
|
+
# Source-map v3 segments encode deltas against the previous segment
|
|
66
|
+
# (gen_col resets per line; the others persist across lines).
|
|
67
|
+
# Segments with fewer than 4 fields (rare — markers without source
|
|
68
|
+
# info) get sentinel `nil`s in their source slots.
|
|
69
|
+
def parse_mappings(mappings)
|
|
70
|
+
lines = mappings.split(';', -1)
|
|
71
|
+
out = Array.new(lines.length) { [] }
|
|
72
|
+
source_idx = 0
|
|
73
|
+
src_line = 0
|
|
74
|
+
src_col = 0
|
|
75
|
+
lines.each_with_index do |line, line_no|
|
|
76
|
+
gen_col = 0
|
|
77
|
+
row = out[line_no]
|
|
78
|
+
line.split(',').each do |seg|
|
|
79
|
+
next if seg.empty?
|
|
80
|
+
fields = decode_vlqs(seg)
|
|
81
|
+
gen_col += fields[0]
|
|
82
|
+
if fields.length >= 4
|
|
83
|
+
source_idx += fields[1]
|
|
84
|
+
src_line += fields[2]
|
|
85
|
+
src_col += fields[3]
|
|
86
|
+
row.push(gen_col, source_idx, src_line, src_col)
|
|
87
|
+
else
|
|
88
|
+
row.push(gen_col, nil, nil, nil)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
out
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def decode_vlqs(segment)
|
|
96
|
+
values = []
|
|
97
|
+
value = 0
|
|
98
|
+
shift = 0
|
|
99
|
+
segment.each_char do |c|
|
|
100
|
+
digit = BASE64_LOOKUP[c]
|
|
101
|
+
raise ArgumentError, "invalid base64 char: #{c.inspect}" unless digit
|
|
102
|
+
continuation = (digit & 0b100000) != 0
|
|
103
|
+
digit &= 0b011111
|
|
104
|
+
value |= digit << shift
|
|
105
|
+
if continuation
|
|
106
|
+
shift += 5
|
|
107
|
+
else
|
|
108
|
+
negative = (value & 1) == 1
|
|
109
|
+
value >>= 1
|
|
110
|
+
values << (negative ? -value : value)
|
|
111
|
+
value = 0
|
|
112
|
+
shift = 0
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
values
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative 'sourcemap'
|
|
7
|
+
|
|
8
|
+
module Capybara
|
|
9
|
+
module Simulated
|
|
10
|
+
# Annotates a V8 stack-trace string with original-source positions
|
|
11
|
+
# from each frame's `.map` companion file. Resolves once per frame,
|
|
12
|
+
# caches loaded Sourcemap objects process-wide (built bundles are
|
|
13
|
+
# fingerprinted; identical URL → identical map).
|
|
14
|
+
#
|
|
15
|
+
# Frames whose URL has no `.map` (snapshot stubs, eval'd app inline
|
|
16
|
+
# scripts) pass through unchanged.
|
|
17
|
+
class StackResolver
|
|
18
|
+
# Matches `(URL:LINE:COL)` inside V8 stack frames. URLs can contain
|
|
19
|
+
# `:` (e.g. `:port`), so the URL class includes `:` and the engine
|
|
20
|
+
# backtracks to find the trailing `:digits:digits)`. `file:` and
|
|
21
|
+
# `csim-eval:` schemes are matched too because both appear in
|
|
22
|
+
# snapshot-warmup frames where the host wraps eval'd content.
|
|
23
|
+
FRAME_RE = /\((?<url>(?:https?:\/\/|file:\/\/|csim-eval:)[^\s()]+):(?<line>\d+):(?<col>\d+)\)/
|
|
24
|
+
|
|
25
|
+
@@maps = {}
|
|
26
|
+
@@lock = Mutex.new
|
|
27
|
+
|
|
28
|
+
def initialize(browser)
|
|
29
|
+
@browser = browser
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the input stack/text with `→ source:line:col` appended to
|
|
33
|
+
# every frame whose URL maps to a `.map`. If no frame resolves, the
|
|
34
|
+
# input is returned unchanged.
|
|
35
|
+
def annotate(text)
|
|
36
|
+
return text unless text.is_a?(String) && !text.empty?
|
|
37
|
+
text.gsub(FRAME_RE) {
|
|
38
|
+
m = ::Regexp.last_match
|
|
39
|
+
url = m[:url]
|
|
40
|
+
line = m[:line].to_i
|
|
41
|
+
col = m[:col].to_i
|
|
42
|
+
resolved = resolve(url, line, col)
|
|
43
|
+
resolved ? "#{m[0]} → #{resolved}" : m[0]
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def resolve(url, line, col)
|
|
50
|
+
map = load_map(url)
|
|
51
|
+
return nil unless map
|
|
52
|
+
pos = map.resolve(line, col)
|
|
53
|
+
return nil unless pos
|
|
54
|
+
"#{pos.source}:#{pos.line}:#{pos.column}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_map(url)
|
|
58
|
+
return @@maps[url] if @@maps.key?(url)
|
|
59
|
+
@@lock.synchronize {
|
|
60
|
+
return @@maps[url] if @@maps.key?(url)
|
|
61
|
+
@@maps[url] = build_map(url)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_map(url)
|
|
66
|
+
body = fetch_map_body(url)
|
|
67
|
+
return nil unless body
|
|
68
|
+
Sourcemap.new(JSON.parse(body))
|
|
69
|
+
rescue JSON::ParserError, ArgumentError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Tries the conventional `<url>.map` location; falls back to the
|
|
74
|
+
# inline `sourceMappingURL` directive at the bottom of the bundle
|
|
75
|
+
# if the sibling `.map` 404s. data: URLs (inline base64 maps) are
|
|
76
|
+
# also supported via `sourceMappingURL`.
|
|
77
|
+
def fetch_map_body(url)
|
|
78
|
+
body = @browser.rack_fetch_body("#{url}.map")
|
|
79
|
+
return body if body
|
|
80
|
+
bundle = @browser.rack_fetch_body(url)
|
|
81
|
+
return nil unless bundle
|
|
82
|
+
directive = bundle[/\/\/[#@]\s*sourceMappingURL=([^\s]+)\s*\z/, 1]
|
|
83
|
+
return nil unless directive
|
|
84
|
+
if directive.start_with?('data:')
|
|
85
|
+
decode_data_url(directive)
|
|
86
|
+
else
|
|
87
|
+
@browser.rack_fetch_body(@browser.resolve_against(directive, url))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode_data_url(uri)
|
|
92
|
+
return nil unless uri =~ %r{\Adata:application/json(?:;charset=[^;,]+)?;base64,(.+)\z}m
|
|
93
|
+
Base64.decode64(::Regexp.last_match(1))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Capybara
|
|
7
|
+
module Simulated
|
|
8
|
+
# Per-test trace of Capybara actions with DOM snapshots, console
|
|
9
|
+
# output, and network requests interleaved. JSON output, one file
|
|
10
|
+
# per test — downstream tooling builds whatever viewer it wants.
|
|
11
|
+
#
|
|
12
|
+
# Off by default. `CSIM_TRACE_DIR=/path/to/dir` enables auto-mode
|
|
13
|
+
# via `Browser#record_action`; the RSpec hook in `csim_rspec.rb`
|
|
14
|
+
# persists with a slugged filename. Programmatic activation is via
|
|
15
|
+
# `Driver#start_tracing` / `#stop_tracing`.
|
|
16
|
+
class Trace
|
|
17
|
+
Step = Struct.new(
|
|
18
|
+
:index,
|
|
19
|
+
:kind,
|
|
20
|
+
:description,
|
|
21
|
+
:url_before,
|
|
22
|
+
:url_after,
|
|
23
|
+
:dom_after, # only the post-action snapshot — the previous step's `dom_after` is the implicit "before"
|
|
24
|
+
:console,
|
|
25
|
+
:network,
|
|
26
|
+
:elapsed_ms,
|
|
27
|
+
:duration_ms,
|
|
28
|
+
:error,
|
|
29
|
+
keyword_init: true
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
attr_reader :steps, :metadata
|
|
33
|
+
|
|
34
|
+
def initialize(metadata: {})
|
|
35
|
+
@steps = []
|
|
36
|
+
@metadata = metadata
|
|
37
|
+
@started_at = monotonic_ms
|
|
38
|
+
@console_buf = []
|
|
39
|
+
@network_buf = []
|
|
40
|
+
@open_step = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Pushed from `Browser#log_console` (the JS bridge's `console.*`
|
|
44
|
+
# host-fn target) and `Browser#rack_request` (network). Entries
|
|
45
|
+
# land on the currently-open step; outside a step they're dropped
|
|
46
|
+
# (boot noise, post-test cleanup).
|
|
47
|
+
def log_console(severity, message)
|
|
48
|
+
return unless @open_step
|
|
49
|
+
@console_buf << {severity: severity.to_s, message: message.to_s}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def log_network(method, url, status)
|
|
53
|
+
return unless @open_step
|
|
54
|
+
@network_buf << {method: method.to_s, url: url.to_s, status: status}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def begin_step(kind, description:, url_before: nil)
|
|
58
|
+
finish_step if @open_step
|
|
59
|
+
@open_step = {
|
|
60
|
+
kind: kind,
|
|
61
|
+
description: description,
|
|
62
|
+
url_before: url_before,
|
|
63
|
+
start_ms: monotonic_ms
|
|
64
|
+
}
|
|
65
|
+
@console_buf = []
|
|
66
|
+
@network_buf = []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def finish_step(url_after: nil, dom_after: nil, error: nil)
|
|
70
|
+
return unless @open_step
|
|
71
|
+
s = @open_step
|
|
72
|
+
@steps << Step.new(
|
|
73
|
+
index: @steps.size,
|
|
74
|
+
kind: s[:kind],
|
|
75
|
+
description: s[:description],
|
|
76
|
+
url_before: s[:url_before],
|
|
77
|
+
url_after: url_after,
|
|
78
|
+
dom_after: dom_after,
|
|
79
|
+
console: @console_buf,
|
|
80
|
+
network: @network_buf,
|
|
81
|
+
elapsed_ms: (s[:start_ms] - @started_at).round,
|
|
82
|
+
duration_ms: (monotonic_ms - s[:start_ms]).round,
|
|
83
|
+
error: error
|
|
84
|
+
)
|
|
85
|
+
@open_step = nil
|
|
86
|
+
@console_buf = []
|
|
87
|
+
@network_buf = []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def empty? = @steps.empty?
|
|
91
|
+
|
|
92
|
+
def to_h
|
|
93
|
+
{version: 1, metadata: @metadata, steps: @steps.map(&:to_h)}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_json(*args) = JSON.generate(to_h, *args)
|
|
97
|
+
|
|
98
|
+
def write_json(path)
|
|
99
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
100
|
+
File.write(path, to_json)
|
|
101
|
+
path
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def monotonic_ms
|
|
107
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|