capybara-simulated 0.10.0 → 0.11.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'base64'
4
+ require 'brotli'
4
5
  require 'date'
5
6
  require 'digest'
6
7
  require 'fileutils'
@@ -2465,6 +2466,7 @@ module Capybara
2465
2466
  # *before* the page's own setup code that the test expects
2466
2467
  # to be active.
2467
2468
  tick_real_time
2469
+ flush_module_rt
2468
2470
  invalidate_find_cache
2469
2471
  # Routes to the active frame realm inside `within_frame` (Selenium
2470
2472
  # parity: `evaluate_script` runs in the current browsing context).
@@ -2479,6 +2481,7 @@ module Capybara
2479
2481
  # …) that the marshaller would recurse into.
2480
2482
  def execute_script(code, args = [])
2481
2483
  tick_real_time
2484
+ flush_module_rt
2482
2485
  invalidate_find_cache
2483
2486
  dom_call('__csimExecScript', code.to_s, marshal_args(args || []))
2484
2487
  drain_pending_navigation
@@ -3350,6 +3353,7 @@ module Capybara
3350
3353
  # is discarding — firing it against the next one would be a load event for a
3351
3354
  # document that never loaded.
3352
3355
  @window_load_due = false
3356
+ @font_file_failed = nil # a 404'd @font-face refetches on the next navigation (Chrome)
3353
3357
  # Background app requests must not cross the session boundary — QUIESCE them
3354
3358
  # before touching anything else. Diagnosed on Discourse (2026-08-22): a
3355
3359
  # leftover async image fetch, holding ActiveRecord's pinned-connection + pool
@@ -3494,10 +3498,97 @@ module Capybara
3494
3498
 
3495
3499
  def rack_fetch_body(url)
3496
3500
  result = rack_fetch('GET', url, '', {}, 'follow')
3501
+ # What the asset's Resource Timing entry reports — kept beside the cached body (see
3502
+ # `external_asset_source`), since the body alone is what the loader hands back.
3503
+ Thread.current[:csim_asset_meta] = result && resource_timing_meta(result)
3497
3504
  return nil unless result && result['status'].to_i < 400
3498
3505
  result['body'].to_s
3499
3506
  end
3500
3507
 
3508
+ # A module the graph loader just fetched (via `rack_fetch_body`, whose facts are stashed in
3509
+ # `csim_asset_meta`) — collected so the bridge can file its 'script' Resource Timing entry.
3510
+ # One per URL: the V8 loader's handle cache already skips a re-import before it fetches, but
3511
+ # the QuickJS loader dedupes compilation only after the block returns, so it fetches a shared
3512
+ # child once per importer — dedup here gives both engines the browser's one-entry-per-URL.
3513
+ def note_module_fetch(url)
3514
+ meta = Thread.current[:csim_asset_meta]
3515
+ return unless meta
3516
+
3517
+ url = url.to_s
3518
+ list = (Thread.current[:csim_module_rt] ||= [])
3519
+ list << {'url' => url, 'meta' => meta} unless list.any? {|m| m['url'] == url }
3520
+ end
3521
+ # The modules fetched since the last call, for the bridge to time as 'script' — cleared on read.
3522
+ def take_module_rt
3523
+ list = Thread.current[:csim_module_rt] || []
3524
+ Thread.current[:csim_module_rt] = []
3525
+ list
3526
+ end
3527
+
3528
+ # The `{url, meta}` for the worker main-script fetch the last `worker_spawn` made (or nil) —
3529
+ # the Worker / SharedWorker constructor reads it right after the spawn to file a 'other'
3530
+ # (classic) / 'script' (module) Resource Timing entry in the creating realm. Cleared on read.
3531
+ def take_worker_rt
3532
+ rt = Thread.current[:csim_worker_rt]
3533
+ Thread.current[:csim_worker_rt] = nil
3534
+ rt
3535
+ end
3536
+
3537
+ # File the Resource Timing entries for any module a dynamic `import()` fetched since the last
3538
+ # drain, before a script reads `performance`. A static `<script type=module>` graph files its
3539
+ # own entries inline (see `runModuleScript`, before the element's `load`); a dynamic import has
3540
+ # no such follow-up, so this read-boundary flush is what files it — at the next driver read, so
3541
+ # every Capybara assertion on `performance` sees it. Two known gaps of this backstop, kept
3542
+ # small deliberately (the alternative — recording in the loader mid-resolution — re-enters V8
3543
+ # from the host during module resolution, which the rusty_racer rendezvous makes risky): a
3544
+ # dynamic import's entry is filed at drain time rather than fetch time, and a page that reads
3545
+ # `performance` from within the import's own `.then` (before any driver read) won't see it yet.
3546
+ # Gated on the thread-local so a module-free page never crosses into JS; routed through
3547
+ # `dom_call` so the flush runs in the active realm (a `within_frame` dynamic import read from a
3548
+ # different realm is filed there, not in the frame's `performance` — the remaining gap).
3549
+ def flush_module_rt
3550
+ return if (Thread.current[:csim_module_rt] || []).empty?
3551
+
3552
+ dom_call('__csimFlushModuleRt')
3553
+ end
3554
+
3555
+ # A resource fetched only for its Resource Timing entry — a `<video>` / `<audio>` / `<embed>` /
3556
+ # `<object>` / `<track>` source the driver does not otherwise decode or play. Returns the fetch
3557
+ # facts (`resource_timing_meta`), or nil when the URL can't resolve; a 4xx/5xx still returns
3558
+ # facts so the entry records, as a browser files one for a failed media load.
3559
+ def resource_timing_fetch(url, cors = false, credentials = 'same-origin')
3560
+ key = resolve_against_current(url.to_s)
3561
+ return nil unless key.is_a?(String)
3562
+ result = rack_fetch('GET', key, '', {}, 'follow', cors ? 'cors' : 'no-cors',
3563
+ credentials: credentials, client_url: @current_url, referrer: @current_url)
3564
+ result && resource_timing_meta(result)
3565
+ end
3566
+
3567
+ # The response facts a `PerformanceResourceTiming` entry is built from, without the body.
3568
+ def resource_timing_meta(result)
3569
+ {
3570
+ 'url' => result['url'],
3571
+ 'status' => result['status'].to_i,
3572
+ 'headers' => {'content-type' => result['headers']&.find {|k, _| k.to_s.casecmp?('content-type') }&.last},
3573
+ 'bytes' => result['bytes'].to_i,
3574
+ 'encoded' => result['encoded'].to_i,
3575
+ 'cached' => result['cached'],
3576
+ 'redirected' => result['redirected'] == true,
3577
+ 'type' => result['type'],
3578
+ 'tao' => result['tao'],
3579
+ 'serverTiming' => result['serverTiming'],
3580
+ 'contentEncoding' => result['contentEncoding']
3581
+ }
3582
+ end
3583
+
3584
+ # The metadata of the asset `external_asset_source` last served for `url` (nil when it
3585
+ # never loaded).
3586
+ def external_asset_meta(url)
3587
+ needs_base = @current_url.to_s.start_with?('blob:', 'data:', 'about:')
3588
+ key = resolve_against_current(url.to_s, use_base: needs_base)
3589
+ key.is_a?(String) ? (@asset_meta ||= {})[key] : nil
3590
+ end
3591
+
3501
3592
  # Fetch a source body and report how long it stays safely reusable per its
3502
3593
  # OWN response headers — an absolute freshness deadline (Time), or nil when
3503
3594
  # the response is not durably cacheable (no-store / no-cache / max-age=0 /
@@ -3552,6 +3643,7 @@ module Capybara
3552
3643
  @@font_file_cache = {}
3553
3644
  @@font_file_lock = Mutex.new
3554
3645
  @@font_files = [] # pins the Tempfiles for the PROCESS (the cache is cross-visit)
3646
+ @@local_font_cache = {} # `local(<name>)` resolution — installed fonts don't change per process
3555
3647
 
3556
3648
  # Empty the process-wide HTTP cache: the RFC 9111 store behind `rack_fetch` plus
3557
3649
  # the URL-keyed memos layered on it — script / stylesheet source, @font-face
@@ -3588,14 +3680,20 @@ module Capybara
3588
3680
  return nil unless key.is_a?(String)
3589
3681
  @@asset_src_lock.synchronize do
3590
3682
  if (e = @@asset_src[key])
3591
- return e[0] if e[1].nil? || Time.now < e[1]
3683
+ if e[1].nil? || Time.now < e[1]
3684
+ (@asset_meta ||= {})[key] = e[2]
3685
+ return e[0]
3686
+ end
3592
3687
  @@asset_src.delete(key)
3593
3688
  end
3594
3689
  end
3595
3690
  # `durable_source` already does the spec-compliant fetch + header-driven
3596
3691
  # freshness (RFC 9111 max-age → absolute deadline); reuse it instead of
3597
3692
  # re-deriving `fresh_until` here.
3693
+ Thread.current[:csim_asset_meta] = nil
3598
3694
  body, fresh_until = durable_source(key)
3695
+ meta = Thread.current[:csim_asset_meta]
3696
+ (@asset_meta ||= {})[key] = meta
3599
3697
  return nil unless body
3600
3698
  # Script / stylesheet source is TEXT, but the raw Rack / binread body
3601
3699
  # arrives BINARY-tagged (see `RuntimeShared.utf8_text`).
@@ -3603,7 +3701,7 @@ module Capybara
3603
3701
  if fresh_until
3604
3702
  @@asset_src_lock.synchronize do
3605
3703
  @@asset_src.clear if @@asset_src.size >= ASSET_SRC_MAX
3606
- @@asset_src[key] = [body, fresh_until]
3704
+ @@asset_src[key] = [body, fresh_until, meta]
3607
3705
  end
3608
3706
  end
3609
3707
  body
@@ -4332,6 +4430,9 @@ module Capybara
4332
4430
  parent_worker = realm_id.to_i.negative? ? -realm_id.to_i : nil
4333
4431
  # The handle counter is bumped from worker threads too (nested spawns) — lock it.
4334
4432
  handle = @worker_init_lock.synchronize { @worker_seq += 1 }
4433
+ # Clear any Resource Timing fact a preceding spawn left, so an early `worker_fail` below (which
4434
+ # still returns a handle the JS registers) can't hand its constructor a stale entry.
4435
+ Thread.current[:csim_worker_rt] = nil
4335
4436
  # A NESTED blob:/data: worker script would need the MAIN VM's blob registry from a
4336
4437
  # non-owning thread (the documented SEGV hazard) — fail it cleanly (onerror), the
4337
4438
  # same observable as before nested workers existed. Marshalling the blob read to
@@ -4379,6 +4480,18 @@ module Capybara
4379
4480
  elsif !sw_script
4380
4481
  fetch_worker_script(target)
4381
4482
  end
4483
+ # Resource Timing for a DEDICATED worker's own main-script fetch — a browser files it in the
4484
+ # creating context's timeline (a classic worker as 'other', a module worker as 'script'; the
4485
+ # JS Worker constructor knows the type and reads this back after the spawn returns, filing it
4486
+ # in its own realm). A SHARED worker's script is NOT a subresource of any one document, so it
4487
+ # generates no entry (resource-timing/shared-worker-rt-entry); nor does a service worker's
4488
+ # registration. Only for a script actually fetched over the network here: a blob:/data: script
4489
+ # has no fetch to time, and an SW-intercepted worker script (`sw_script`, deferred) is timed
4490
+ # by the SW path. `csim_asset_meta` is the fetch fact `fetch_worker_script`'s `rack_fetch_body`
4491
+ # just stashed (cleared at the top of the spawn so a failed one can't leak).
4492
+ if !service && !shared && !sw_script && body && target.match?(%r{\Ahttps?://}i)
4493
+ Thread.current[:csim_worker_rt] = {'url' => target, 'meta' => Thread.current[:csim_asset_meta]}
4494
+ end
4382
4495
  # A blob: worker script that didn't resolve (revoked / unavailable) fails the
4383
4496
  # same way — fire onerror rather than spawn a worker that runs nothing.
4384
4497
  return worker_fail(handle, 'Worker script could not be loaded') if target.start_with?('blob:') && body.to_s.empty?
@@ -7184,6 +7297,7 @@ module Capybara
7184
7297
  def load_image(url, cors = false, credentials = 'same-origin')
7185
7298
  key = resolve_against_current(url.to_s)
7186
7299
  return nil unless key.is_a?(String)
7300
+ Thread.current[:csim_image_meta] = nil
7187
7301
  entry = cached_image(key, cors, credentials)
7188
7302
  return {'unsupported' => true} if entry == :unsupported
7189
7303
  # A valid zero-area image: complete + not broken, but no pixels. rsvg throws
@@ -7193,8 +7307,8 @@ module Capybara
7193
7307
  # non-zero area.
7194
7308
  tainted = image_tainted?(key, cors)
7195
7309
  return {'zeroSize' => true, 'width' => 0, 'height' => 0, 'tainted' => tainted} if entry == :zero_size
7196
- return nil unless entry
7197
- r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace'], 'tainted' => tainted}
7310
+ return undecodable_image_result unless entry
7311
+ r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace'], 'tainted' => tainted, 'encoded' => entry['encoded'], 'meta' => Thread.current[:csim_image_meta]}
7198
7312
  r['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
7199
7313
  r
7200
7314
  end
@@ -7258,12 +7372,19 @@ module Capybara
7258
7372
  # The thread-side body: everything except the transfer stash (engine-affine, done at
7259
7373
  # delivery on the main thread). Result mirrors `load_image`'s shapes with the raw entry.
7260
7374
  private def image_load_result(key, cors, credentials, origin_base)
7375
+ Thread.current[:csim_image_meta] = nil
7261
7376
  entry = cached_image(key, cors, credentials, origin_base: origin_base)
7262
7377
  return {'unsupported' => true} if entry == :unsupported
7263
7378
  tainted = origin_tainted?(key, cors, client_url: origin_base)
7264
7379
  return {'zeroSize' => true, 'width' => 0, 'height' => 0, 'tainted' => tainted} if entry == :zero_size
7265
- return nil unless entry
7266
- { entry: entry, tainted: tainted }
7380
+ return undecodable_image_result unless entry
7381
+ { entry: entry, tainted: tainted, meta: Thread.current[:csim_image_meta] }
7382
+ end
7383
+ # A response that arrived but is no image: the element is broken, the resource was still
7384
+ # fetched — its Resource Timing entry carries the real status and size.
7385
+ private def undecodable_image_result
7386
+ meta = Thread.current[:csim_image_meta]
7387
+ meta ? {'broken' => true, 'meta' => meta} : nil
7267
7388
  end
7268
7389
 
7269
7390
  def image_loads_pending?
@@ -7319,7 +7440,7 @@ module Capybara
7319
7440
  return nil if r.nil?
7320
7441
  return r unless r.is_a?(Hash) && r.key?(:entry)
7321
7442
  entry = r[:entry]
7322
- out = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace'], 'tainted' => r[:tainted]}
7443
+ out = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace'], 'tainted' => r[:tainted], 'encoded' => entry['encoded'], 'meta' => r[:meta]}
7323
7444
  out['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
7324
7445
  out
7325
7446
  end
@@ -7365,6 +7486,7 @@ module Capybara
7365
7486
  entry = decode_or_nil(bytes)
7366
7487
  # nil (broken) and :zero_size (valid but zero-area) both carry no bitmap to cache.
7367
7488
  return entry unless entry.is_a?(Hash)
7489
+ entry['encoded'] = bytes.bytesize # the resource's size for its Resource Timing entry
7368
7490
  @@image_cache_lock.synchronize do
7369
7491
  @@image_cache.clear if @@image_cache.size >= IMAGE_CACHE_MAX
7370
7492
  @@image_cache[cache_key] = entry
@@ -7389,6 +7511,18 @@ module Capybara
7389
7511
  # issued this request.
7390
7512
  result = rack_fetch('GET', key, '', {}, 'follow', cors ? 'cors' : nil, credentials: credentials,
7391
7513
  client_url: origin_base, referrer: origin_base, body_raw: true)
7514
+ # What the image's Resource Timing entry reports of the response — its content type,
7515
+ # whether it was redirected to, its status and size — read back on this thread right
7516
+ # after; a 404 is a response too (the element breaks, the entry keeps the status).
7517
+ if result
7518
+ Thread.current[:csim_image_meta] = {
7519
+ 'contentType' => result['headers'].find {|k, _| k.to_s.casecmp?('content-type') }&.last,
7520
+ 'tao' => result['tao'],
7521
+ 'redirected' => result['redirected'] == true,
7522
+ 'status' => result['status'].to_i,
7523
+ 'encoded' => result['encoded'].to_i
7524
+ }
7525
+ end
7392
7526
  return nil unless result && result['status'].to_i < 400
7393
7527
  bytes = result['body_raw'].to_s
7394
7528
  bytes.empty? ? nil : bytes
@@ -7542,6 +7676,11 @@ module Capybara
7542
7676
  # back to the mean.
7543
7677
  private def build_font_advance_table(family, weight_style)
7544
7678
  file = font_file_for_family(family, weight_style) or return nil
7679
+ font_table_from_file(file)
7680
+ end
7681
+ # The table `__csim_fontAdvances` hands the flow, from one font FILE: printable-ASCII
7682
+ # advances as em fractions, their mean, the x-height and the hhea line metrics.
7683
+ private def font_table_from_file(file)
7545
7684
  g = font_glyph_data(file) or return nil
7546
7685
  upm = g[:upm].to_f
7547
7686
  return nil unless upm.positive?
@@ -8061,17 +8200,41 @@ module Capybara
8061
8200
  # the bytes through the Rack app (binary-safe) once and caching the temp path for
8062
8201
  # the process. Returns nil when the fetch fails.
8063
8202
  def font_file_for(url)
8203
+ font_file_and_meta_for(url).first
8204
+ end
8205
+
8206
+ # `[path, meta]`: the face's file on disk (nil when it could not be fetched or read) and
8207
+ # the facts of the fetch that brought it (`resource_timing_meta`); a cross-visit cache hit
8208
+ # reports the same facts as served from the cache (Chrome files a cached font with
8209
+ # `transferSize` 0).
8210
+ def font_file_and_meta_for(url)
8064
8211
  key = resolve_against_current(url.to_s)
8065
- return nil unless key.is_a?(String)
8066
- @@font_file_lock.synchronize { return @@font_file_cache[key] if @@font_file_cache.key?(key) }
8212
+ return [nil, nil] unless key.is_a?(String)
8213
+ @@font_file_lock.synchronize do
8214
+ if @@font_file_cache.key?(key)
8215
+ path, meta = @@font_file_cache[key]
8216
+ return [path, meta && meta.merge('cached' => 'cache')]
8217
+ end
8218
+ end
8219
+ if (failed = (@font_file_failed ||= {})[key])
8220
+ return [nil, failed]
8221
+ end
8222
+ Thread.current[:csim_font_meta] = nil
8067
8223
  path = build_font_file(key)
8068
- @@font_file_lock.synchronize { @@font_file_cache[key] = path }
8069
- path
8224
+ meta = Thread.current[:csim_font_meta]
8225
+ if path
8226
+ @@font_file_lock.synchronize { @@font_file_cache[key] = [path, meta] }
8227
+ else
8228
+ @font_file_failed[key] = meta # remembered for THIS session only
8229
+ end
8230
+ [path, meta]
8070
8231
  end
8071
8232
 
8072
8233
  private def build_font_file(key)
8073
8234
  bytes = font_source_bytes(key)
8074
8235
  return nil unless bytes && !bytes.empty?
8236
+ bytes = woff_to_sfnt(bytes)
8237
+ return nil unless bytes
8075
8238
  require 'tempfile'
8076
8239
  # Keep the Tempfile object alive for the process so its file isn't reaped while
8077
8240
  # fontconfig may still read it; the cache holds the path.
@@ -8093,13 +8256,213 @@ module Capybara
8093
8256
  bytes = decode_data_url_body(key)
8094
8257
  bytes.empty? ? nil : bytes
8095
8258
  elsif key.match?(%r{\Ahttps?://}i)
8096
- result = rack_fetch('GET', key, '', {}, 'follow', body_raw: true)
8259
+ # A font is a CORS fetch (CSS Fonts 4 §4.9.1): a cross-origin face without
8260
+ # `Access-Control-Allow-Origin` fails, as in Chrome.
8261
+ result = rack_fetch('GET', key, '', {}, 'follow', 'cors', credentials: 'same-origin',
8262
+ client_url: @current_url, referrer: @current_url, body_raw: true)
8263
+ # The fetch's facts for the font's Resource Timing entry (read back by the caller
8264
+ # on this thread), a 404 included.
8265
+ Thread.current[:csim_font_meta] = result && resource_timing_meta(result)
8097
8266
  return nil unless result && result['status'].to_i < 400
8098
8267
  bytes = result['body_raw'].to_s
8099
8268
  bytes.empty? ? nil : bytes
8100
8269
  end
8101
8270
  end
8102
8271
 
8272
+ # A WOFF container holds an SFNT's tables compressed; the metrics parser and pango want the
8273
+ # plain SFNT, so it is rebuilt here. WOFF 1.0 (`wOFF`) zlib-inflates each table on its own;
8274
+ # WOFF 2.0 (`wOF2`) Brotli-decompresses all of them as one stream and drops the glyf/loca
8275
+ # GLYPH transform (which measurement doesn't need). Anything else is already an SFNT and is
8276
+ # returned unchanged; nil means "not a font we can rebuild" and the face measures with the
8277
+ # fallback family, as a WOFF2 file did before there was a decoder.
8278
+ def woff_to_sfnt(bytes)
8279
+ # Every parse below advances a byte cursor with `getbyte` but slices with `String#[]`,
8280
+ # which counts CHARACTERS — so a font body that arrived tagged UTF-8 (a Rack app that did
8281
+ # not mark its font response binary) would misalign the moment a high byte before an offset
8282
+ # formed a valid multibyte sequence. Force binary once, at the door.
8283
+ bytes = bytes.b
8284
+ return woff1_to_sfnt(bytes) if bytes.bytesize > 44 && bytes[0, 4] == 'wOFF'
8285
+ return woff2_to_sfnt(bytes) if bytes.bytesize > 48 && bytes[0, 4] == 'wOF2'
8286
+ bytes
8287
+ end
8288
+
8289
+ private def woff1_to_sfnt(bytes)
8290
+ flavor, _len, num = bytes[4, 10].unpack('a4Nn') # flavor @4, length @8, numTables @12
8291
+ return nil unless num.positive? && bytes.bytesize >= 44 + num * 20
8292
+ entries = (0...num).map {|i|
8293
+ tag, off, comp, orig = bytes[44 + i * 20, 20].unpack('a4NNN') # csum @16 recomputed on assembly
8294
+ return nil if off + comp > bytes.bytesize
8295
+ data = bytes[off, comp].to_s
8296
+ data = Zlib::Inflate.inflate(data) if comp < orig
8297
+ [tag, data]
8298
+ }
8299
+ assemble_sfnt(flavor, entries)
8300
+ rescue StandardError
8301
+ nil
8302
+ end
8303
+
8304
+ # WOFF 2.0: the 48-byte header, a compact table directory (a flags byte, an optional tag,
8305
+ # then UIntBase128 lengths), and a single Brotli stream holding every table's data in
8306
+ # directory order. A `glyf` / `loca` pair is stored in a transformed GLYPH representation
8307
+ # (its outlines), which the metrics tables — `head` / `hhea` / `hmtx` / `cmap` / `maxp` /
8308
+ # `OS/2`, none of which an encoder transforms — don't depend on, so the transformed tables
8309
+ # are skipped and the rest assembled into a metrics-faithful SFNT. `ttcf` collections and a
8310
+ # transformed metric table (a rare `hmtx` transform) fall through to the fallback family.
8311
+ WOFF2_KNOWN_TAGS = %w[
8312
+ cmap head hhea hmtx maxp name OS/2 post cvt\ fpgm glyf loca prep CFF\ VORG EBDT EBLC gasp
8313
+ hdmx kern LTSH PCLT VDMX vhea vmtx BASE GDEF GPOS GSUB EBSC JSTF MATH CBDT CBLC COLR CPAL
8314
+ SVG\ sbix acnt avar bdat bloc bsln cvar fdsc feat fmtx fvar gcid hsty just lcar mort morx
8315
+ opbd prop trak Zapf Silf Glat Gloc Feat Sill
8316
+ ].freeze
8317
+ private def woff2_to_sfnt(bytes)
8318
+ flavor = bytes[4, 4]
8319
+ return nil if flavor == 'ttcf' # font collections: not rebuilt
8320
+ num, total_comp = bytes[12, 2].unpack1('n'), bytes[20, 4].unpack1('N')
8321
+ return nil unless num.positive?
8322
+ i = 48
8323
+ tables = []
8324
+ num.times do
8325
+ return nil if i >= bytes.bytesize
8326
+ flags = bytes.getbyte(i); i += 1
8327
+ idx = flags & 0x3f
8328
+ if idx == 0x3f # 63: an arbitrary 4-byte tag follows
8329
+ tag = bytes[i, 4]; i += 4
8330
+ else
8331
+ tag = WOFF2_KNOWN_TAGS[idx] or return nil
8332
+ end
8333
+ orig, i = uint_base128(bytes, i)
8334
+ return nil unless orig
8335
+ transformed = (tag == 'glyf' || tag == 'loca') ? ((flags >> 6).zero?) : ((flags >> 6) != 0)
8336
+ len = orig
8337
+ if transformed
8338
+ len, i = uint_base128(bytes, i)
8339
+ return nil unless len
8340
+ end
8341
+ tables << [tag, len, transformed]
8342
+ end
8343
+ comp = bytes[i, total_comp]
8344
+ return nil unless comp && comp.bytesize == total_comp
8345
+ data = brotli_decompress(comp) or return nil
8346
+ # Slice each table out of the decompressed stream (directory order), keeping only the ones
8347
+ # stored untransformed — the transformed glyf/loca occupy their bytes in the stream but are
8348
+ # glyph outlines, not metrics.
8349
+ off = 0
8350
+ entries = []
8351
+ tables.each do |tag, len, transformed|
8352
+ entries << [tag, data[off, len]] unless transformed
8353
+ off += len
8354
+ end
8355
+ return nil if off > data.bytesize || entries.empty?
8356
+ assemble_sfnt(flavor, entries)
8357
+ rescue StandardError
8358
+ nil
8359
+ end
8360
+
8361
+ # A UIntBase128 (WOFF2 §4.4): up to five 7-bit groups, most significant first, the top bit a
8362
+ # continuation flag. Returns `[value, next_index]`, or `[nil, i]` on a malformed encoding (a
8363
+ # leading `0x80`, over five bytes, or a value past 2³²−1) so the caller bails to the fallback.
8364
+ private def uint_base128(bytes, i)
8365
+ v = 0
8366
+ 5.times do |n|
8367
+ return [nil, i] if i >= bytes.bytesize
8368
+ b = bytes.getbyte(i); i += 1
8369
+ return [nil, i] if n.zero? && b == 0x80 # no leading zero group
8370
+ return [nil, i] if v > 0x01ff_ffff # would overflow 32 bits after the shift
8371
+ v = (v << 7) | (b & 0x7f)
8372
+ return [v, i] if (b & 0x80).zero?
8373
+ end
8374
+ [nil, i]
8375
+ end
8376
+
8377
+ # Assemble an SFNT from `[tag, data]` tables: the 12-byte offset table, a directory sorted by
8378
+ # tag (spec order), then each table's data padded to a 4-byte boundary with a freshly summed
8379
+ # table checksum. Shared by both WOFF rebuilders.
8380
+ private def assemble_sfnt(flavor, entries)
8381
+ entries = entries.map {|tag, data| [tag.to_s, data.to_s] }.sort_by {|tag, _| tag }
8382
+ num = entries.size
8383
+ entry_selector = Math.log2(num).floor
8384
+ search_range = (2**entry_selector) * 16
8385
+ out = [flavor, num, search_range, entry_selector, num * 16 - search_range].pack('a4nnnn')
8386
+ offset = 12 + num * 16
8387
+ dir = +''
8388
+ body = +''
8389
+ entries.each do |tag, data|
8390
+ dir << [tag, sfnt_table_checksum(data), offset + body.bytesize, data.bytesize].pack('a4NNN')
8391
+ body << data << ("\0" * ((4 - data.bytesize % 4) % 4))
8392
+ end
8393
+ (out << dir << body).b
8394
+ end
8395
+
8396
+ # An SFNT table checksum: the sum of its 32-bit big-endian words (zero-padded to a 4-byte
8397
+ # boundary), truncated to 32 bits.
8398
+ private def sfnt_table_checksum(data)
8399
+ padded = data + ("\0" * ((4 - data.bytesize % 4) % 4))
8400
+ padded.unpack('N*').sum & 0xffff_ffff
8401
+ end
8402
+
8403
+ # Brotli-decompress a raw stream (a WOFF2 font block) with the `brotli` gem (in-process, its
8404
+ # own vendored C library). A decode error yields nil, so a malformed face falls back to the
8405
+ # substitute family rather than crashing.
8406
+ private def brotli_decompress(data)
8407
+ out = Brotli.inflate(data)
8408
+ out && out.b
8409
+ rescue StandardError
8410
+ nil
8411
+ end
8412
+
8413
+ # The advance table of a downloaded face (an `@font-face` src), fetched through the
8414
+ # HTTP layer like any resource, with the fetch's facts for its Resource Timing entry.
8415
+ # `table` is nil when the file is no SFNT the parser reads (WOFF2, a 404, a broken file).
8416
+ def font_advance_table_from_url(url)
8417
+ file, meta = font_file_and_meta_for(url)
8418
+ # `ok`: bytes arrived (a data: face has no fetch facts; an unreadable container no
8419
+ # a face's `status` follows.
8420
+ {'table' => file ? font_table_from_file(file) : nil, 'meta' => meta,
8421
+ 'ok' => !file.nil? || (meta && meta['status'].to_i.between?(200, 399)) == true}
8422
+ end
8423
+
8424
+ # A `local(<name>)` `@font-face` source: the advance table of the font INSTALLED under that
8425
+ # name, or `ok: false` when this machine has no such font (fontconfig SUBSTITUTES silently, so
8426
+ # `resolved_family_file` — the same exact-match test the family stack uses — is what tells a
8427
+ # real installed face from a fallback). A face whose every `local()` misses and that has no
8428
+ # readable `url()` then fails, as a browser rejects a UA font load it cannot satisfy.
8429
+ def local_font_table(name, weight_style = '')
8430
+ key = "#{name} #{weight_style}"
8431
+ @@font_file_lock.synchronize do
8432
+ return @@local_font_cache[key] if @@local_font_cache.key?(key)
8433
+ end
8434
+ file = resolved_family_file(name.to_s, weight_style.to_s)
8435
+ result = file ? {'table' => font_table_from_file(file), 'ok' => true} : {'table' => nil, 'ok' => false}
8436
+ @@font_file_lock.synchronize { @@local_font_cache[key] = result }
8437
+ result
8438
+ end
8439
+
8440
+ # A face's own bytes (a `FontFace` built from a buffer, a `blob:` src): parsed like a
8441
+ # downloaded file. `ok` is whether the bytes are a recognised font container (an
8442
+ # OpenType-CFF buffer loads even though the metrics parser reads no glyf/loca table from
8443
+ # it, so it measures with the fallback family). Content-addressed so identical bytes reuse
8444
+ # one temp file, not one per call.
8445
+ FONT_MAGIC = ["\x00\x01\x00\x00".b, 'OTTO'.b, 'true'.b, 'ttcf'.b, 'wOFF'.b, 'wOF2'.b].freeze
8446
+ def font_advance_table_from_bytes(b64)
8447
+ bytes = Base64.decode64(b64.to_s)
8448
+ ok = bytes.bytesize >= 4 && FONT_MAGIC.include?(bytes[0, 4].b)
8449
+ return {'table' => nil, 'ok' => ok} unless ok
8450
+ sfnt = woff_to_sfnt(bytes)
8451
+ return {'table' => nil, 'ok' => true} if sfnt.nil? || sfnt.bytesize < 12
8452
+ key = "bytes:#{Digest::SHA256.hexdigest(sfnt)}"
8453
+ path = @@font_file_lock.synchronize { @@font_file_cache[key]&.first }
8454
+ unless path
8455
+ require 'tempfile'
8456
+ file = Tempfile.new(['csim-font', '.bin'])
8457
+ file.binmode
8458
+ file.write(sfnt)
8459
+ file.flush
8460
+ @@font_file_lock.synchronize { @@font_files << file; @@font_file_cache[key] = [file.path, nil] }
8461
+ path = file.path
8462
+ end
8463
+ {'table' => font_table_from_file(path), 'ok' => true}
8464
+ end
8465
+
8103
8466
  def reset_workers
8104
8467
  # Ask first, and stop the JS half in the same pass: the `:terminate` message is only read
8105
8468
  # BETWEEN messages, so a worker inside a long JS call would otherwise not see it until that
@@ -9271,10 +9634,12 @@ module Capybara
9271
9634
 
9272
9635
  def resolve_against(url, base)
9273
9636
  return url if url =~ %r{\A[a-z]+://}i
9274
- # quickjs.rb's module_loader passes the importer for nested
9275
- # relative imports; if the importer was an inline-script
9276
- # pseudo-name (no scheme), fall through to the page URL.
9277
- base = nil unless base.is_a?(String) && base =~ %r{\A[a-z]+://}i
9637
+ # quickjs.rb's module_loader passes the importer for nested relative imports (and for a
9638
+ # dynamic `import()`, the importer is the referring script). An inline script / module has no
9639
+ # real URL — its pseudo-name is `<eval>` (no scheme, from V8) or `inline://<hash>` (from
9640
+ # `__csim_runScript`) so a specifier must resolve against the PAGE URL, not the pseudo-name.
9641
+ # Fall through for a base that is not a real URL or is one of those `inline:` pseudo-names.
9642
+ base = nil unless base.is_a?(String) && base =~ %r{\A[a-z]+://}i && !base.start_with?('inline:')
9278
9643
  eff = base || @current_url || @default_host
9279
9644
  # Memo of `URI.join(eff, url)` — a pure function of (effective base, url).
9280
9645
  # A heavy ESM app re-resolves the same ~80 module specifiers against the
@@ -9539,7 +9904,7 @@ module Capybara
9539
9904
  end
9540
9905
  # Cached asset — log headers/type/size but skip the (boring) body.
9541
9906
  trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
9542
- return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected, body_raw: body_raw)
9907
+ return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected, body_raw: body_raw, cached: 'cache', encoded: cache_entry.encoded)
9543
9908
  end
9544
9909
  # only-if-cached forbids the network: no usable stored response → a network error.
9545
9910
  return nil if cache_mode == 'only-if-cached'
@@ -9687,7 +10052,7 @@ module Capybara
9687
10052
  # be re-filtered through the CORS exposed-header set on the way back to script —
9688
10053
  # a 304 revalidation must not leak headers the original cross-origin fetch hid.
9689
10054
  cached_headers = cross_origin ? cors_exposed_headers(cache_entry.headers, with_credentials) : cache_entry.headers
9690
- return response_hash(cache_entry.status, cached_headers, cache_entry.body, target, redirected, body_raw: body_raw)
10055
+ return response_hash(cache_entry.status, cached_headers, cache_entry.body, target, redirected, body_raw: body_raw, cached: 'validated', raw_headers: cache_entry.headers, encoded: cache_entry.encoded)
9691
10056
  end
9692
10057
  # Fetch "CORS check" runs on EVERY cross-origin response — including a 3xx the
9693
10058
  # UA is about to follow (a redirect whose response lacks a valid Access-Control
@@ -9797,7 +10162,9 @@ module Capybara
9797
10162
  null_body = method.to_s.upcase == 'HEAD' || NULL_BODY_STATUSES.include?(status.to_i)
9798
10163
  body_str = '' if null_body
9799
10164
  # The UA transparently decodes a Content-Encoding'd body (gzip/deflate); the
9800
- # header stays, the bytes are inflated (response-data-gzip / -deflate).
10165
+ # header stays, the bytes are inflated (response-data-gzip / -deflate). The size on
10166
+ # the wire is what Resource Timing's `encodedBodySize` reports.
10167
+ encoded_size = body_str.bytesize
9801
10168
  body_str = decode_content_encoding(body_str, resp_headers)
9802
10169
  # A cross-origin response only EXPOSES (getResponseHeader / getAllResponseHeaders)
9803
10170
  # the CORS-safelisted response headers plus those named in Access-Control-Expose
@@ -9817,12 +10184,12 @@ module Capybara
9817
10184
  # the author's own conditional bypasses the UA cache entirely (read AND write) — it's
9818
10185
  # "treated similarly to no-store" (request-cache-default-conditional). Every other mode
9819
10186
  # (incl. reload, which refreshes it) stores a cacheable GET response.
9820
- @@asset_cache.store(target, status, resp_headers, body_str) if method == 'GET' && cache_mode != 'no-store' && !skip_cache
10187
+ @@asset_cache.store(target, status, resp_headers, body_str, encoded: encoded_size) if method == 'GET' && cache_mode != 'no-store' && !skip_cache
9821
10188
  # A no-cors cross-origin response is OPAQUE: status 0, empty body, no exposed
9822
10189
  # headers, empty URL (cors-basic "Opaque filter"). Otherwise the type is 'cors'
9823
10190
  # for a cross-origin (CORS-allowed) response, else 'basic'.
9824
- return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true, opaque_render: body_str) if no_cors_mode && crossed
9825
- return response_hash(status, exposed_headers, body_str, target, redirected, type: crossed ? 'cors' : 'basic', body_null: null_body, body_raw: body_raw)
10191
+ return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true, opaque_render: body_str, encoded: encoded_size) if no_cors_mode && crossed
10192
+ return response_hash(status, exposed_headers, body_str, target, redirected, type: crossed ? 'cors' : 'basic', body_null: null_body, body_raw: body_raw, encoded: encoded_size, raw_headers: resp_headers)
9826
10193
  end
9827
10194
  raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
9828
10195
  rescue StandardError => e
@@ -9931,15 +10298,28 @@ module Capybara
9931
10298
  # that decodes the bytes here and never shows them to script): the bytes ride
9932
10299
  # `body_raw` untouched and the text decode + base64 are skipped — they would be
9933
10300
  # ~15 ms per MB of pure waste on a path that runs for EVERY image load.
9934
- def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false, opaque_render: nil, body_raw: false)
10301
+ def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false, opaque_render: nil, body_raw: false, cached: nil, encoded: nil, raw_headers: nil)
9935
10302
  raw = body.to_s
9936
10303
  hdrs = stringify(headers)
10304
+ # Resource Timing's sizes: the decoded body, the body on the wire (before a
10305
+ # Content-Encoding was undone), and whether the HTTP cache served it — fresh
10306
+ # (`'cache'`: nothing crossed the wire) or after a 304 (`'validated'`: headers did) —
10307
+ # plus the three headers its checks read from the UNFILTERED response (a cross-origin
10308
+ # fetch exposes only the CORS-safelisted headers to script, and none of these is).
10309
+ timing = {'bytes' => raw.bytesize, 'encoded' => encoded || raw.bytesize, 'cached' => cached}
10310
+ (raw_headers || headers).each do |k, v|
10311
+ case k.to_s.downcase
10312
+ when 'timing-allow-origin' then timing['tao'] = v.is_a?(Array) ? v.join(', ') : v.to_s
10313
+ when 'server-timing' then timing['serverTiming'] = v.is_a?(Array) ? v.join(', ') : v.to_s
10314
+ when 'content-encoding' then timing['contentEncoding'] = v.is_a?(Array) ? v.join(', ') : v.to_s
10315
+ end
10316
+ end
9937
10317
  # A NUL in a header value is not a valid HTTP message; a real server can't
9938
10318
  # put it on the wire, so the fetch is a network error (nil → status 0 / a
9939
10319
  # thrown NetworkError for a sync XHR). See headers-normalize-response.
9940
10320
  return nil if hdrs.any? {|_, v| v.include?("\u0000") }
9941
10321
  if body_raw
9942
- return {
10322
+ return timing.merge(
9943
10323
  'status' => status,
9944
10324
  'statusText' => '',
9945
10325
  'headers' => hdrs,
@@ -9948,7 +10328,7 @@ module Capybara
9948
10328
  'url' => url,
9949
10329
  'redirected' => redirected,
9950
10330
  'type' => type
9951
- }
10331
+ )
9952
10332
  end
9953
10333
  is_text = text_response?(hdrs)
9954
10334
  # `body` crosses as TEXT — `responseText` semantics: the bytes decoded
@@ -9979,7 +10359,7 @@ module Capybara
9979
10359
  # off the document URL — the same signal WPT uses to serve the resource over h2
9980
10360
  # (fetch/xhr status.h2 "statusText over H2 … should be the empty string").
9981
10361
  reason = '' if @current_url.to_s.include?('.h2.')
9982
- out = {
10362
+ out = timing.merge(
9983
10363
  'status' => status,
9984
10364
  'statusText' => reason,
9985
10365
  'headers' => hdrs,
@@ -9987,7 +10367,7 @@ module Capybara
9987
10367
  'url' => url,
9988
10368
  'redirected' => redirected,
9989
10369
  'type' => type
9990
- }
10370
+ )
9991
10371
  out['body_null'] = true if body_null # null-body status / HEAD → response.body is null
9992
10372
  # The BOM-detected encoding (if any) — a frame load pins its document's
9993
10373
  # characterSet to it (see __csimFrameWindow); highest-precedence signal.