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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/capybara/simulated/asset_cache.rb +6 -3
- data/lib/capybara/simulated/browser.rb +407 -27
- data/lib/capybara/simulated/js/bridge.bundle.js +36424 -26793
- data/lib/capybara/simulated/quickjs_runtime.rb +11 -10
- data/lib/capybara/simulated/runtime_shared.rb +7 -0
- data/lib/capybara/simulated/v8_runtime.rb +4 -1
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +15 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
9276
|
-
# pseudo-name (no scheme
|
|
9277
|
-
|
|
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.
|