Skip to content

Open mode: a site's service worker serves app content for Cypress's runner document (browser network path) #34789

Description

@cacieprins

Current behavior

In open mode on the browser network path, a site's own service worker can serve the site's content in place of Cypress's runner document. The user sees the site's 404 page where the Cypress app should be.

The mechanism: the browser network path serves the runner document on the origin under test. With baseUrl: https://app.example.com, the top frame is literally https://app.example.com/__/#/specs/runner?.... Many production sites register a service worker scoped at / (verified against several major ones). That scope entitles the worker to answer for /__/, which is Cypress's own document.

Two navigations hit this today:

Navigation Trigger
Launch Open mode keeps a persistent browser profile. A worker registered in an earlier Cypress session survives. Clicking a spec navigates the top frame to the runner URL, and the leftover worker answers first.
Runner reload The user reloads the browser tab mid-session. The now-running worker answers for the reload.

Interception cannot save the launch case: the navigation that cold-starts a worker pauses on no CDP session at all (established empirically in #34674 and #34757). Run mode is unaffected only because its fresh profile has no worker at first navigation — which is also why CI never caught this. The BROWSER_NETWORK_INTERCEPTION_ESCAPE tripwire fires, but it is a warning, not a recovery.

This is distinct from #34652 (closed). That issue was the navigation-preload escape. #34671 closed it; #34711 fixed the sibling ServiceWorkerAutoPreload escape (#34709). This issue is the sequel: the runner's own document, not the AUT's in-test requests.

Desired behavior

Cypress always serves its own runner document. An application service worker never answers for a Cypress-reserved path (/__/, /__cypress/, the cloud bundle namespaces), on any navigation, predicted or not.

Test code to reproduce

  1. cypress open on Chrome with forceHttp1 unset and baseUrl set to the origin of any site that registers a root-scoped service worker (check with navigator.serviceWorker.getRegistrations() in the console — a registration whose scope is https://<host>/).
  2. Run any spec that visits the site, so its service worker registers. End the Cypress session.
  3. Reopen Cypress and click a spec: the site's 404 renders where the runner should be.
  4. Same effect without a relaunch: after step 2's spec runs, reload the browser tab.

A hostile minimal reproduction (root-scope worker answering /__/assets/e2e-poison* with a marker body) lives in system-tests/projects/e2e/static/framebusting/ as of PR #34762.

Cypress Version

16.0.0 (present at release; the 16.0.0 release notes do not mention it)

Debug Logs

Signature, not required for triage: a BROWSER_NETWORK_INTERCEPTION_ESCAPE warning naming a URL whose path starts with /__/. Under DEBUG=cypress:server:browsers:interception-escape-detector, the escape logs as a service-worker-served document with no Fetch pause.

Other

Fix is up in PR #34762 (two layers: instrumented workers decline runner-namespace requests; persisted worker state is cleared before the runner navigation). Workarounds until it ships in 16.0.1: use cypress run; or clear the browser profile / the site data for the AUT's origin; or set forceHttp1: true.

Attn Agents:

Machine-oriented section; humans see the summary above.

Mechanism. browserUrl = origin(baseUrl) + clientRoute (packages/config/src/utils.ts setUrls), so the runner document lives on the AUT origin under clientRoute (default /__/). A root-scope app SW is in scope for it. Cold-start navigations pause on no CDP session (harness data in #34757 body); cache-first workers answer from Cache API with no network request; so interception alone cannot protect the document.

Evidence. Standalone CDP harness, Chrome 152: a warm worker's DECLINED navigation (handler runs, no respondWith) falls through and pauses as a normal Fetch.requestPaused on the page session — fully interceptable. respondWith(fetch(e.request)) pauses on the worker session as XHR. Distinguishable and both safe. Cold-start and cache-first behavior independently re-verified on Chrome 152 (2/2 trials each); note that during a cold start a racing network fetch can reach the origin even though no Fetch.requestPaused fires anywhere — "no pause" is not "no packet". Negative result ruling out the rival design: Network.setBypassServiceWorker arm/clear (PR #34757) must enumerate every navigation path, cannot see user reloads, and a missed clear silently disables the app's worker mid-test — superseded, do not revive.

Fix design (PR #34762). (1) packages/proxy/lib/http/util/service-worker-injector.ts: wrapped fetch listeners decline paths under clientRoute, /${namespace}/, /__cypress-studio, /__cypress-cy-prompt (bare match mirroring isCloudBundleNamespace, packages/server/lib/adapters/internal-routes.ts), emitting fetchRequest {isControlled:false}; exemption JSON-embedded as the serialized IIFE's argument, threaded from MaybeInjectServiceWorker only when useBrowserNetworkInterception. (2) packages/server/lib/browsers/chrome.ts attachListeners: Storage.clearDataForOrigin {origin:'*', storageTypes:'service_workers,cache_storage'} on the page client before _navigateUsingCRI, gated on BrowserLaunchOpts.shouldClearPersistedServiceWorkers = browser-network path AND project-level testIsolation !== false.

Constraints. Injector file is Function.prototype.toString()-serialized: self only, no bare window/document/global/process/console (v8 snapshot doctor rewrites them into get_*() shims — silent, CI-only). SW script must stay byte-identical across update checks: serve cacheable + updateViaCache: 'all' in fixtures. MITM path (forceHttp1) is deliberately inert. testIsolation: false residual risk accepted: persisted never-instrumented worker + unpredicted navigation = one loudly-explained failure (tripwire copy names the remedy). cy-in-cy cannot host an e2e proof: with CYPRESS_INTERNAL_E2E_TESTING_SELF set, open_project.ts routes relaunches through connectToExisting, whose client uses fullyManageTabs: false; the Target.attachedToTarget listener that consumes worker attach events — and installs the __cypressServiceWorkerClientEvent binding — is registered only when fullyManageTabs is true, and the injected install handler waits for that binding unboundedly — no SW can install under cy-in-cy at all.

Fallback. None needed; both layers landed. If the decline rule ever regresses, the launch clear still covers persisted-profile launches, and forceHttp1: true remains the user-side escape.

Verification plan. Unit: packages/proxy/test/unit/http/util/service-worker-injector.spec.ts (executes the serialized string), chrome_spec.ts clear-before-navigate ordering pins, server-base_spec.ts escape-label predicate (mutation-verified). System: system-tests/test/proxy_disabled_framebusting_spec.ts — browser-network variant asserts the SW's marker body cannot come back for a reserved path, forceHttp1 variant asserts it does; red/green verified against the rule reverted.

Interim mitigation. cypress run; clear profile/site data; forceHttp1: true. Do not close this issue on the injector rule alone — the launch clear is what covers never-instrumented persisted workers.

Adjacent bugs surfaced by this work, not yet filed as issues. (a) An AUT request to a clientRoute path no Express route owns hangs forever on the browser network path — the loopback re-entry 404 never releases the outer CDP pause. (b) The injector's install-time binding wait has no timeout, wedging every registration in binding-less environments. (c) cy-in-cy's connectToExisting never consumes worker attach events, so no service worker can install there (blocks all SW e2e coverage under cy-in-cy).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions