Skip to content

fix: capture network on Safari and Appium sessions without BiDi - #437

Open
vishnuv688 wants to merge 17 commits into
mainfrom
fix/network-resource-timing
Open

vishnuv688 wants to merge 17 commits into
mainfrom
fix/network-resource-timing

Conversation

@vishnuv688

Copy link
Copy Markdown
Member

Summary

Reported on the launch post: network calls are not available for Safari and Appium. Network capture rode on WebDriver BiDi, or on Chrome's performance log in Nightwatch, and neither exists on Safari or on a session Appium serves, so those sessions showed an empty Network tab.

  • The page collector reads the document's Resource Timing buffer (packages/script/src/collectors/resourceTiming.ts). Every browser keeps it: each load with its timing phases and size, on Chromium its status, never headers or bodies. The buffer is read at install, so loads made before the collector existed are included, then observed. The collector's existing fetch/XMLHttpRequest patch claims each call it sees, so a request both saw is listed once with the patch's headers and body.
  • A driver-side source wins once it has delivered, not once it is subscribed. On Appium 3.7's XCUITest driver the BiDi inspectors attach cleanly and then send nothing, so gating on the subscription withheld the page's rows and left zero network rows. Each adapter now latches on the first BiDi network event it receives and gates on that; the Selenium adapter applies the same rule to its console gate.
  • Nightwatch's performance log upgrades the page's row in place. The log is read after the page is drained, so each entry is paired with the page's row for the same load and lays its headers and status over it under the row's id. A load the log listed first is dropped from the next drain. A session whose capabilities name a performance log the driver never serves is no longer left empty.
  • The Python adapter no longer treats safaridriver's echoed webSocketUrl: true as a BiDi endpoint. It is a boolean, not a socket address, and selenium's connection thread crashed on it.
  • What Resource Timing cannot see is stated. The row carries source: 'resource-timing', the Network tab colours a status-less row as unknown rather than pending, the detail card names what the browser does not expose, and the HAR entry carries _source and _resourceType so a reopened trace renders the same row rather than a failed request.

Found on the way and fixed in its own commit: Selenium and Nightwatch traces on Chrome listed every BiDi request twice, a pending row beside the completed one. selenium-webdriver's NetworkInspector hands every network event to every subscriber's callback, so the request handler also received responses and the response handler received requests. packages/core/src/bidi.ts now ignores the other handler's events by shape, keeps rows by request id so a redirect hop continues its row, and reads network.fetchError off the inspector's own connection so a request the browser gives up on closes as a failure instead of staying pending.

Native app sessions have no document and still have no network capture; only a device- or proxy-level capture sees that traffic.

Verification

Platform WDIO Selenium Nightwatch Python
Chrome (BiDi) unchanged one row per request, no duplicates one row per request, no duplicates unchanged
Desktop Safari 26.5 Resource Timing rows Resource Timing rows Resource Timing rows Resource Timing rows
iOS 26.5 Safari, Appium 3.7 Resource Timing rows Resource Timing rows Resource Timing rows Resource Timing rows
Android not verified, no SDK on this machine
  • pnpm test: 2599 tests across 196 files, green.
  • pnpm lint: 0 errors. tsc --noEmit: clean. pnpm build: clean, and the workspace-package leak grep over every dist is empty.
  • pnpm test:ui: 31 specs green.
  • Python suite: 723 passed; the 2 failures also fail on main.

Follow-ups, not in this PR

  • The WebdriverIO website docs need the same "without BiDi" paragraph mirrored; this PR updates the READMEs and examples/MOBILE.md here.
  • The Python adapter's login example on desktop Safari is flaky before and after this change, documented in CLAUDE.md. Network capture worked in every run.
  • Android device verification.
  • Conformance coverage for the Network tab in the three BiDi modes is on a separate branch.

🤖 Generated with Claude Code

vishnuv688 and others added 4 commits October 15, 2026 15:11
selenium-webdriver's NetworkInspector hands every network event to every
subscriber's callback, so the request handler also received responses and
the response handler also received requests. Every request in a Selenium
Chrome trace was listed twice, a pending row beside the completed one, and
57 of 135 rows in a Nightwatch trace. Each handler now ignores the other's
events by their shape.

The inspector also models neither a redirect hop, which re-sends the
request under the same id, nor a failed fetch, for which it has no hook.
Rows are kept by id for the session so a hop continues its row under the
first hop's clock, and network.fetchError is read off the inspector's own
connection so a request the browser gives up on closes as a failure
instead of staying pending. A browser that refuses that subscription
keeps the inspector and loses only the failure rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Network capture rode on WebDriver BiDi, or on Chrome's performance log in
Nightwatch, so Safari and every Appium session showed an empty Network
tab. The injected page collector now also reads the document's Resource
Timing buffer, which every browser keeps: each load with its timing
phases and size, on Chromium its status, never headers or bodies. It
reads the buffer at install so loads made before the collector existed
are included, then observes. The collector's fetch/XHR patch claims each
call it sees, so a request both saw is listed once with the patch's
headers and body, and an entry that arrives before the call has answered
is held until the patch records or drops that call.

A driver-side source wins once it has delivered. The gate is evidence,
not a subscription: on Appium 3.7's XCUITest driver the BiDi inspectors
attach and then send nothing, so each adapter latches on the first BiDi
network event it receives and gates the page's rows on that. Nightwatch's
performance log, read after the drain, lays its headers and status over
the page's row for the same load instead of listing it again, and a load
the log listed first is dropped from the next drain. The Python adapter
also stops treating safaridriver's echoed `webSocketUrl: true` as a BiDi
endpoint, which crashed selenium's connection thread.

What Resource Timing cannot see is stated rather than left blank: the row
carries its source, the dashboard colours a status-less row as unknown
rather than pending and names the limit on the detail card, and the HAR
entry carries `_source` and `_resourceType` so a reopened trace renders
the same row rather than a failed request.

Verified on desktop Safari 26.5 and on iOS 26.5 Safari through Appium
3.7 with all four adapters; Chrome sessions are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The login example on desktop Safari fails to leave /login after the
submit click in most runs, with and without the network changes, while
network capture works in every run. The suspect is the adapter's
per-command work after a click on Safari; it is unresolved, and the
Nightwatch BDD example's assertions are not Safari-proof either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… demo

The demo credential trips Chrome's password-leak check, after which
Chrome drops every WebDriver-synthesized input to the tab and the
logout click silently does nothing. The other examples that submit it
already map the check's endpoint to localhost; this one did not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with a non-blocking slowdown in network capture on long runs.

Findings

  1. P2 Network capture slows over time ▶

Summary

Adds page-based network capture for Safari and Appium sessions without working BiDi events.

  • Safari and Appium sessions now show network rows from the page.
  • Nightwatch performance logs fill in matching page rows.
  • BiDi request events now update one network row per request.
  • Selenium keeps Safari in front and instruments new pages before commands continue.
  • Wait polls now fold into the command that started them.
  • Shared request helpers give captured loads consistent types and URL filtering.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Page requests] --> B[Fetch and XHR patch]
  A --> C[Resource Timing]
  B --> D[Match claims and keep one row]
  C --> D
  D --> E[Adapter drain]
  F[Delivered BiDi events] --> E
  E --> G[Dashboard and saved trace]
Loading

Reviews (4) · Last reviewed commit: "fix(selenium): fold only a wait()'s own ..." · Reviewed by Greptile

Comment thread packages/nightwatch-devtools/src/helpers/perfLogs.ts Outdated
Comment thread packages/selenium-devtools/README.md Outdated
vishnuv688 and others added 10 commits October 15, 2026 15:53
…earby request

The drain filter dropped a drained Resource Timing row whenever any row
the session held without that source shared its url within 2 s. That
set includes the collector's own patched fetch rows and rows the log had
already upgraded, so a second request to the same url inside the window
was read as a second listing of the first and lost its only row: a fetch
answered with a content-type-less 204 after one answered with JSON never
reached the Network tab or the trace.

The session now keeps the loads the performance log listed before the
page's drain could carry them, and the next drain spends each on one
page row, the nearest in time under its url. Patched rows and upgraded
rows are never candidates. The ledger is spent at the next drain that
reaches the driver, a collector-less document included, and emptied at
a session replacement, because the page observed the load before the
log could list it: a row absent from that drain is never coming, and a
load kept longer would take the row of the next request to the same url.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lback

The fallback was described as seeing no headers or bodies, and on Safari
no status, as if that held for the whole injected path. Only the
Resource Timing rows have those limits; a fetch or XMLHttpRequest call
the collector patched carries its method, headers, status and body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Core's injection gate and the page collector's request filter each kept their
own list of schemes no server answers, and they had drifted: the collector
missed chrome-native:, the new-tab page Chrome on Android opens a session on,
and listed it as a request. Both now read shared/browser-schemes.ts, which
also parses the scheme properly so a relative path that merely starts like
one is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Safari and every Appium session the collector is appended as a <script>
after each navigation, and four things went wrong there:

- It waited for an animation frame before attaching its listeners, which
  Safari's automation window does not fire until something forces a paint,
  so a live Safari run captured no typing and every row replayed the same DOM.
  It now starts at once when the page body already exists.
- Two injections racing past one presence probe replaced the collector and
  dropped its buffers; on Android Chrome the trace had no network rows. A
  second evaluation now keeps the collector it finds.
- The DOM anchor read attributes only, so fields typed into before the
  collector arrived replayed empty. It now records each field's live value
  and checked state, except a password's.
- A reused XMLHttpRequest re-ran every earlier send's listeners, and a claim
  pushed out of the 500-request window lost the entry it held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
selenium-webdriver's NetworkInspector has a fetchError hook, so failed
requests now subscribe through it and fall back to reading the socket only
for an inspector without one. The rows kept for a session now evict finished
requests first, so a long-running request still open past 5000 others keeps
a row for its answer to land in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
selenium-webdriver's wait() runs its first poll inside the test's call and the
rest from a timer, which carries no call site, so the retry fold never matched
them; and because onCommand is not awaited, a poll could be captured before
the previous one had an id. Captures now run one at a time per capturer, and a
repeat without a call site folds into the row the user's line produced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Safari delivers synthesized input only to its frontmost window and reports
success either way, so once the live dashboard took the front every click
reached nothing and a login never left /login. The WDIO service, Nightwatch
and Python adapters now run open -a Safari after session bring-up in live
mode, for a local desktop Safari only: never an iOS session through Appium or
one served from another host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nput

Without document-start injection the collector was appended from the
unawaited onCommand after get() returned, so typing that followed raced it.
A navigation now resolves to the test only once the destination is
instrumented. Before each element click, sendKeys, clear or submit on a
Safari session the plugin brings the session's own window forward when it
has lost focus (open -a Safari can surface the user's other Safari window
instead), leaving a test inside a frame alone, and the dashboard opens behind
Safari on macOS. Measured: 3 of 3 live Safari logins, against 0 of 2 before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The adapter subscribed to request and response events only, so a blocked
beacon or refused connection stayed pending for the session. It now
subscribes network.fetchError and closes the row with status 0 and the
browser's reason, separately from the pair so an endpoint that lacks the
event keeps the rest of network capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md, the changeset, the Python release note and the Selenium README
describe the collector, focus and wait() changes above, and replace the
earlier note that blamed Safari's lost clicks on adapter flakiness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/script/src/collectors/networkRequests.ts
vishnuv688 and others added 3 commits October 16, 2026 19:42
A claim pushed out of the 500-request window listed its held Resource Timing
row at once; when the page's own call then completed, the patch listed the
same request again. The call's row now replaces the early one, or takes its
id when that row was already drained so the dashboard replaces it in place,
and the adapter-side list replaces a re-sent id the same way. The record
methods return the row they pushed rather than the array's last entry, which
a concurrent call could own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An appended script runs synchronously, so the injection now returns the
readiness check itself and polls only when the collector is not up yet;
pollUntilReady gains checkFirst for the same reason. On Safari and Appium,
where every navigation injects, this removes at least 200 ms per navigation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A command with no call site of the test's own was folded into the previous
row whenever it repeated it, which also swallowed a helper library's call.
The patched wait() now runs its condition inside an AsyncLocalStorage, so
only the commands that condition issues are marked as polls, per driver and
per async chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +502 to +504
const at = req.id
? this.networkRequests.findIndex((r) => r.id === req.id)
: -1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Network capture slows over time

Every incoming request now runs findIndex over all requests saved so far, including new ids. Capturing N requests therefore takes roughly N²/2 comparisons instead of appending them directly. Busy pages and long runs spend increasing time draining network data. Use an id lookup so replacing a released row does not require scanning the whole recording.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant