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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -158
  3. data/lib/capybara/simulated/asset_cache.rb +232 -0
  4. data/lib/capybara/simulated/browser.rb +3409 -845
  5. data/lib/capybara/simulated/driver.rb +341 -134
  6. data/lib/capybara/simulated/errors.rb +9 -5
  7. data/lib/capybara/simulated/js/bridge.bundle.js +19738 -0
  8. data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
  9. data/lib/capybara/simulated/node.rb +151 -163
  10. data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
  11. data/lib/capybara/simulated/runtime_shared.rb +183 -0
  12. data/lib/capybara/simulated/script_cache.rb +168 -0
  13. data/lib/capybara/simulated/sourcemap.rb +119 -0
  14. data/lib/capybara/simulated/stack_resolver.rb +97 -0
  15. data/lib/capybara/simulated/trace.rb +111 -0
  16. data/lib/capybara/simulated/v8_runtime.rb +987 -0
  17. data/lib/capybara/simulated/version.rb +3 -1
  18. data/lib/capybara/simulated/webauthn_state.rb +367 -0
  19. data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
  20. data/lib/capybara/simulated/worker_runtime.rb +30 -0
  21. data/lib/capybara/simulated.rb +31 -4
  22. data/lib/capybara-simulated.rb +2 -0
  23. data/vendor/js/vendor.bundle.js +13 -0
  24. metadata +24 -32
  25. data/vendor/esbuild-wasm/LICENSE.md +0 -21
  26. data/vendor/esbuild-wasm/bin/esbuild +0 -91
  27. data/vendor/esbuild-wasm/esbuild.wasm +0 -0
  28. data/vendor/esbuild-wasm/lib/main.js +0 -2337
  29. data/vendor/esbuild-wasm/wasm_exec.js +0 -575
  30. data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
  31. data/vendor/js/bundle-modules.mjs +0 -168
  32. data/vendor/js/csim.bundle.js +0 -91560
  33. data/vendor/js/entry.mjs +0 -23
  34. data/vendor/js/prelude.js +0 -190
  35. 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