From a7cedfeef130835f46f0822823962f7a082e113f Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 12 Dec 2023 23:31:49 -0600 Subject: [PATCH] fix: ensure that we capture service worker requests --- packages/proxy/lib/http/index.ts | 20 +- packages/proxy/lib/http/util/prerequests.ts | 53 ++- .../lib/http/util/service-worker-manager.ts | 155 +++++++ packages/proxy/lib/network-proxy.ts | 17 +- packages/proxy/lib/types.ts | 3 + packages/proxy/package.json | 1 + .../test/unit/http/util/prerequests.spec.ts | 138 +++++- .../http/util/service-worker-manager.spec.ts | 427 ++++++++++++++++++ packages/server/lib/automation/automation.ts | 4 +- .../server/lib/browsers/cdp_automation.ts | 16 +- packages/server/lib/browsers/chrome.ts | 2 +- packages/server/lib/browsers/electron.ts | 2 +- packages/server/lib/browsers/utils.ts | 107 +++-- .../server/lib/browsers/webkit-automation.ts | 1 + packages/server/lib/project-base.ts | 15 +- packages/server/lib/server-base.ts | 15 +- .../test/unit/browsers/cdp_automation_spec.ts | 85 +++- .../server/test/unit/browsers/chrome_spec.js | 7 +- .../test/unit/browsers/electron_spec.js | 4 +- packages/types/src/server.ts | 7 + .../protocol-stubs/protocolStubResponse.ts | 2 + .../protocolStubServiceWorker.ts | 114 +++++ .../e2e/cypress/e2e/service_worker.cy.js | 10 +- .../e2e/service-worker-assets/example.json | 3 + .../e2e/service-worker-assets/scope/load.js | 8 + .../scope/service-worker.js | 28 ++ .../scope}/service_worker.html | 4 +- .../protocol/cypress/e2e/service-worker.cy.js | 22 + .../service-worker-assets/example.json | 3 + .../scope/cached-service-worker.json | 3 + .../service-worker-assets/scope/load.js | 8 + .../scope/service-worker.js | 28 ++ .../scope/service_worker.html | 11 + .../test/service_worker_protocol_spec.js | 56 +++ system-tests/test/service_worker_spec.js | 2 +- 35 files changed, 1321 insertions(+), 60 deletions(-) create mode 100644 packages/proxy/lib/http/util/service-worker-manager.ts create mode 100644 packages/proxy/test/unit/http/util/service-worker-manager.spec.ts create mode 100644 system-tests/lib/protocol-stubs/protocolStubServiceWorker.ts create mode 100644 system-tests/projects/e2e/service-worker-assets/example.json create mode 100644 system-tests/projects/e2e/service-worker-assets/scope/load.js create mode 100644 system-tests/projects/e2e/service-worker-assets/scope/service-worker.js rename system-tests/projects/e2e/{ => service-worker-assets/scope}/service_worker.html (61%) create mode 100644 system-tests/projects/protocol/cypress/e2e/service-worker.cy.js create mode 100644 system-tests/projects/protocol/cypress/fixtures/service-worker-assets/example.json create mode 100644 system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/cached-service-worker.json create mode 100644 system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/load.js create mode 100644 system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service-worker.js create mode 100644 system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service_worker.html create mode 100644 system-tests/test/service_worker_protocol_spec.js diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index a1957649ec9d..cbfe29892fca 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -26,6 +26,7 @@ import type { RemoteStates } from '@packages/server/lib/remote_states' import type { CookieJar, SerializableAutomationCookie } from '@packages/server/lib/util/cookies' import type { ResourceTypeAndCredentialManager } from '@packages/server/lib/util/resourceTypeAndCredentialManager' import type { ProtocolManagerShape } from '@packages/types' +import type Protocol from 'devtools-protocol' function getRandomColorFn () { return chalk.hex(`#${Number( @@ -429,10 +430,13 @@ export class Http { } } - reset () { + reset (options: { fullReset?: boolean } = {}) { this.buffers.reset() this.setAUTUrl(undefined) - this.preRequests.reset() + + if (options.fullReset) { + this.preRequests.reset() + } } setBuffer (buffer) { @@ -451,6 +455,18 @@ export class Http { this.preRequests.addPendingUrlWithoutPreRequest(url) } + updateServiceWorkerRegistrations (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) { + this.preRequests.updateServiceWorkerRegistrations(data) + } + + updateServiceWorkerVersions (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) { + this.preRequests.updateServiceWorkerVersions(data) + } + + updateServiceWorkerClientSideRegistrations (data: { scriptURL: string, initiatorURL: string }) { + this.preRequests.updateServiceWorkerClientSideRegistrations(data) + } + setProtocolManager (protocolManager: ProtocolManagerShape) { this.protocolManager = protocolManager this.preRequests.setProtocolManager(protocolManager) diff --git a/packages/proxy/lib/http/util/prerequests.ts b/packages/proxy/lib/http/util/prerequests.ts index 86b2cd7f9f30..c14877a53741 100644 --- a/packages/proxy/lib/http/util/prerequests.ts +++ b/packages/proxy/lib/http/util/prerequests.ts @@ -5,6 +5,8 @@ import type { } from '@packages/proxy' import type { ProtocolManagerShape } from '@packages/types' import Debug from 'debug' +import { ServiceWorkerManager } from './service-worker-manager' +import type Protocol from 'devtools-protocol' const debug = Debug('cypress:proxy:http:util:prerequests') const debugVerbose = Debug('cypress-verbose:proxy:http:util:prerequests') @@ -108,6 +110,7 @@ export class PreRequests { pendingUrlsWithoutPreRequests = new QueueMap() sweepIntervalTimer: NodeJS.Timeout protocolManager?: ProtocolManagerShape + serviceWorkerManager: ServiceWorkerManager = new ServiceWorkerManager() constructor ( requestTimeout = 2000, @@ -147,8 +150,23 @@ export class PreRequests { } addPending (browserPreRequest: BrowserPreRequest) { - metrics.browserPreRequestsReceived++ const key = `${browserPreRequest.method}-${decodeURI(browserPreRequest.url)}` + + // The initial request that loads the service worker does not always get sent to CDP. Thus, we need to explicitly ignore it. We determine + // it's the service worker request via the `service-worker` header + if (browserPreRequest.headers?.['Service-Worker'] === 'script') { + debugVerbose('Ignoring service worker script since we are not guaranteed to receive it', key) + + return + } + + if (this.serviceWorkerManager.processBrowserPreRequest(browserPreRequest)) { + debugVerbose('Not correlating request since it is fully controlled by service worker and the correlation will happen within the service worker', key) + + return + } + + metrics.browserPreRequestsReceived++ const pendingRequest = this.pendingRequests.shift(key) if (pendingRequest) { @@ -220,11 +238,10 @@ export class PreRequests { } get (req: CypressIncomingRequest, ctxDebug, callback: GetPreRequestCb) { - // The initial request that loads the service worker does not get sent to CDP and it happens prior - // to the service worker target being added. Thus, we need to explicitly ignore it. We determine - // it's the service worker request via the `sec-fetch-dest` header - if (req.headers['sec-fetch-dest'] === 'serviceworker') { - ctxDebug('Ignoring request with sec-fetch-dest: serviceworker', req.proxiedUrl) + // The initial request that loads the service worker does not always get sent to CDP. Thus, we need to explicitly ignore it. We determine + // it's the service worker request via the `service-worker` header + if (req.headers?.['service-worker'] === 'script') { + ctxDebug('Ignoring service worker script since we are not guaranteed to receive it', req.proxiedUrl) callback({ noPreRequestExpected: true, @@ -242,6 +259,7 @@ export class PreRequests { if (pendingPreRequest) { metrics.immediatelyMatchedRequests++ ctxDebug('Incoming request %s matches known pre-request: %o', key, pendingPreRequest) + callback({ browserPreRequest: { ...pendingPreRequest.browserPreRequest, @@ -305,7 +323,30 @@ export class PreRequests { delete pendingRequest.callback } + updateServiceWorkerRegistrations (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) { + data.registrations.forEach((registration) => { + if (registration.isDeleted) { + this.serviceWorkerManager.unregisterServiceWorker({ registrationId: registration.registrationId }) + } else { + this.serviceWorkerManager.registerServiceWorker({ registrationId: registration.registrationId, scopeURL: registration.scopeURL }) + } + }) + } + + updateServiceWorkerVersions (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) { + data.versions.forEach((version) => { + if (version.status === 'activated') { + this.serviceWorkerManager.addActivatedServiceWorker({ registrationId: version.registrationId, scriptURL: version.scriptURL }) + } + }) + } + + updateServiceWorkerClientSideRegistrations (data: { scriptURL: string, initiatorURL: string }) { + this.serviceWorkerManager.addInitiatorToServiceWorker({ scriptURL: data.scriptURL, initiatorURL: data.initiatorURL }) + } + reset () { + this.serviceWorkerManager = new ServiceWorkerManager() this.pendingPreRequests = new QueueMap() // Clear out the pending requests timeout callbacks first then clear the queue diff --git a/packages/proxy/lib/http/util/service-worker-manager.ts b/packages/proxy/lib/http/util/service-worker-manager.ts new file mode 100644 index 000000000000..45d080febea8 --- /dev/null +++ b/packages/proxy/lib/http/util/service-worker-manager.ts @@ -0,0 +1,155 @@ +import type { BrowserPreRequest } from '../../types' + +type ServiceWorkerRegistration = { + registrationId: string + scopeURL: string + activatedServiceWorker?: ServiceWorker +} + +type ServiceWorker = { + registrationId: string + scriptURL: string + initiatorURL?: string + controlledURLs: Set +} + +type RegisterServiceWorkerOptions = { + registrationId: string + scopeURL: string +} + +type UnregisterServiceWorkerOptions = { + registrationId: string +} + +type AddActivatedServiceWorkerOptions = { + registrationId: string + scriptURL: string +} + +type AddInitiatorToServiceWorkerOptions = { + scriptURL: string + initiatorURL: string +} + +/** + * Manages service worker registrations and their controlled URLs. + * + * The basic lifecycle is as follows: + * + * 1. A service worker is registered via `registerServiceWorker`. + * 2. The service worker is activated via `addActivatedServiceWorker`. + * + * At some point while 1 and 2 are happening: + * + * 3. We receive a message from the browser that a service worker has been registered with the `addInitiatorToServiceWorker` method. + * + * At this point, when the manager tries to process a browser pre-request, it will check if the request is controlled by a service worker. + * It determines it is controlled by a service worker if: + * + * 1. The document URL for the browser pre-request matches the initiator URL for the service worker. + * 2. The request URL is within the scope of the service worker or the request URL's initiator is controlled by the service worker. + */ +export class ServiceWorkerManager { + private serviceWorkerRegistrations: Map = new Map() + private pendingInitiators: Map = new Map() + + /** + * Registers the given service worker with the given scope. Will not overwrite an existing registration. + */ + registerServiceWorker ({ registrationId, scopeURL }: RegisterServiceWorkerOptions) { + // Only register service workers if they haven't already been registered + if (this.serviceWorkerRegistrations.has(registrationId) && this.serviceWorkerRegistrations.get(registrationId)?.scopeURL === scopeURL) { + return + } + + this.serviceWorkerRegistrations.set(registrationId, { + registrationId, + scopeURL, + }) + } + + /** + * Unregisters the service worker with the given registration ID. + */ + unregisterServiceWorker ({ registrationId }: UnregisterServiceWorkerOptions) { + this.serviceWorkerRegistrations.delete(registrationId) + } + + /** + * Adds an activated service worker to the manager. + */ + addActivatedServiceWorker ({ registrationId, scriptURL }: AddActivatedServiceWorkerOptions) { + const registration = this.serviceWorkerRegistrations.get(registrationId) + + if (registration) { + const initiatorURL = this.pendingInitiators.get(scriptURL) + + registration.activatedServiceWorker = { + registrationId, + scriptURL, + controlledURLs: new Set(), + initiatorURL: initiatorURL || registration.activatedServiceWorker?.initiatorURL, + } + + this.pendingInitiators.delete(scriptURL) + } + } + + /** + * Adds an initiator URL to a service worker. If the service worker has not yet been activated, the initiator URL is added to a pending list and will + * be added to the service worker when it is activated. + */ + addInitiatorToServiceWorker ({ scriptURL, initiatorURL }: AddInitiatorToServiceWorkerOptions) { + let initiatorAdded = false + + this.serviceWorkerRegistrations.forEach((registration) => { + if (registration.activatedServiceWorker && registration.activatedServiceWorker.scriptURL === scriptURL) { + registration.activatedServiceWorker.initiatorURL = initiatorURL + + initiatorAdded = true + } + }) + + if (!initiatorAdded) { + this.pendingInitiators.set(scriptURL, initiatorURL) + } + } + + /** + * Processes a browser pre-request to determine if it is controlled by a service worker. If it is, the service worker's controlled URLs are updated with the given request URL. + * + * @param browserPreRequest The browser pre-request to process. + * @returns `true` if the request is controlled by a service worker, `false` otherwise. + */ + processBrowserPreRequest (browserPreRequest: BrowserPreRequest) { + if (browserPreRequest.initiator?.type === 'preload') { + return false + } + + let requestControlledByServiceWorker = false + + this.serviceWorkerRegistrations.forEach((registration) => { + const activatedServiceWorker = registration.activatedServiceWorker + const paramlessDocumentURL = browserPreRequest.documentURL.split('?')[0] + + if (!activatedServiceWorker || activatedServiceWorker.initiatorURL !== paramlessDocumentURL) { + return + } + + const paramlessURL = browserPreRequest.url.split('?')[0] + const paramlessInitiatorURL = browserPreRequest.initiator?.url?.split('?')[0] + const paramlessCallStackURL = browserPreRequest.initiator?.stack?.callFrames[0]?.url?.split('?')[0] + const urlIsControlled = paramlessURL.startsWith(registration.scopeURL) + const initiatorUrlIsControlled = paramlessInitiatorURL && activatedServiceWorker.controlledURLs?.has(paramlessInitiatorURL) + const topStackUrlIsControlled = paramlessCallStackURL && activatedServiceWorker.controlledURLs?.has(paramlessCallStackURL) + + if (urlIsControlled || initiatorUrlIsControlled || topStackUrlIsControlled) { + activatedServiceWorker.controlledURLs.add(paramlessURL) + requestControlledByServiceWorker = true + } + }) + + return requestControlledByServiceWorker + } +} diff --git a/packages/proxy/lib/network-proxy.ts b/packages/proxy/lib/network-proxy.ts index a3c4996450ad..15068643dd6a 100644 --- a/packages/proxy/lib/network-proxy.ts +++ b/packages/proxy/lib/network-proxy.ts @@ -1,6 +1,7 @@ import { telemetry } from '@packages/telemetry' import { Http, ServerCtx } from './http' import type { BrowserPreRequest } from './types' +import type Protocol from 'devtools-protocol' export class NetworkProxy { http: Http @@ -21,6 +22,18 @@ export class NetworkProxy { this.http.addPendingUrlWithoutPreRequest(url) } + updateServiceWorkerRegistrations (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) { + this.http.updateServiceWorkerRegistrations(data) + } + + updateServiceWorkerVersions (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) { + this.http.updateServiceWorkerVersions(data) + } + + updateServiceWorkerClientSideRegistrations (data: { scriptURL: string, initiatorURL: string }) { + this.http.updateServiceWorkerClientSideRegistrations(data) + } + handleHttpRequest (req, res) { const span = telemetry.startSpan({ name: 'network:proxy:handleHttpRequest', @@ -46,8 +59,8 @@ export class NetworkProxy { this.http.setBuffer(buffer) } - reset () { - this.http.reset() + reset (options: { fullReset?: boolean } = {}) { + this.http.reset(options) } setProtocolManager (protocolManager) { diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index 8ef5433ae368..3fb51cc692a2 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -3,6 +3,7 @@ import type { Request, Response } from 'express' import type { ProxyTimings } from '@packages/types' import type { ResourceType } from '@packages/net-stubbing' import type { BackendRoute } from '@packages/net-stubbing/lib/server/types' +import type { Protocol } from 'devtools-protocol' /** * An incoming request to the Cypress web server. @@ -63,6 +64,8 @@ export type BrowserPreRequest = { resourceType: ResourceType originalResourceType: string | undefined errorHandled?: boolean + initiator?: Protocol.Network.Initiator + documentURL: string cdpRequestWillBeSentTimestamp: number cdpRequestWillBeSentReceivedTimestamp: number } diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 8fdd6880cd8d..37eeeb1571db 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -36,6 +36,7 @@ "@packages/server": "0.0.0-development", "@types/express": "4.17.2", "@types/supertest": "2.0.10", + "devtools-protocol": "0.0.927104", "express": "4.17.3", "supertest": "6.0.1", "typescript": "^4.7.4" diff --git a/packages/proxy/test/unit/http/util/prerequests.spec.ts b/packages/proxy/test/unit/http/util/prerequests.spec.ts index 37d2b9b966a8..1f3a49a6d13c 100644 --- a/packages/proxy/test/unit/http/util/prerequests.spec.ts +++ b/packages/proxy/test/unit/http/util/prerequests.spec.ts @@ -37,6 +37,7 @@ describe('http/util/prerequests', () => { headers: {}, resourceType: 'xhr', originalResourceType: undefined, + documentURL: 'foo', cdpRequestWillBeSentTimestamp: 1, cdpRequestWillBeSentReceivedTimestamp: 2, }) @@ -48,6 +49,7 @@ describe('http/util/prerequests', () => { headers: {}, resourceType: 'xhr', originalResourceType: undefined, + documentURL: 'foo', cdpRequestWillBeSentTimestamp: 1, cdpRequestWillBeSentReceivedTimestamp: performance.now() + performance.timeOrigin + 10000, } @@ -60,6 +62,7 @@ describe('http/util/prerequests', () => { headers: {}, resourceType: 'xhr', originalResourceType: undefined, + documentURL: 'foo', cdpRequestWillBeSentTimestamp: 1, cdpRequestWillBeSentReceivedTimestamp: 2, }) @@ -161,6 +164,7 @@ describe('http/util/prerequests', () => { headers: {}, resourceType: 'xhr', originalResourceType: undefined, + documentURL: 'foo', cdpRequestWillBeSentTimestamp: 1, cdpRequestWillBeSentReceivedTimestamp: performance.now() + performance.timeOrigin + 10000, } @@ -230,7 +234,7 @@ describe('http/util/prerequests', () => { it('immediately handles a request from a service worker loading', () => { const cbServiceWorker = sinon.stub() - preRequests.get({ proxiedUrl: 'foo', method: 'GET', headers: { 'sec-fetch-dest': 'serviceworker' } } as any, () => {}, cbServiceWorker) + preRequests.get({ proxiedUrl: 'foo', method: 'GET', headers: { 'service-worker': 'script' } } as any, () => {}, cbServiceWorker) expect(cbServiceWorker).to.be.calledOnce expect(cbServiceWorker).to.be.calledWith() @@ -257,7 +261,7 @@ describe('http/util/prerequests', () => { expectPendingCounts(0, 0) }) - it('resets the queues', () => { + it('resets the queues and service worker manager', () => { let callbackCalled = false preRequests.addPending({ requestId: '1234', url: 'bar', method: 'GET' } as BrowserPreRequest) @@ -267,13 +271,20 @@ describe('http/util/prerequests', () => { preRequests.addPendingUrlWithoutPreRequest('baz') + preRequests.serviceWorkerManager.registerServiceWorker({ + registrationId: '1234', + scopeURL: 'foo', + }) + expectPendingCounts(1, 1, 1) + expect(preRequests.serviceWorkerManager['serviceWorkerRegistrations'].size).to.eq(1) preRequests.reset() expectPendingCounts(0, 0, 0) expect(callbackCalled).to.be.true + expect(preRequests.serviceWorkerManager['serviceWorkerRegistrations'].size).to.eq(0) }) it('decodes the proxied url', () => { @@ -296,4 +307,127 @@ describe('http/util/prerequests', () => { expect(preRequests.pendingPreRequests.length).to.eq(1) expect(preRequests.pendingPreRequests.shift('GET-foo|bar')).not.to.be.undefined }) + + it('properly ignores service worker prerequests', () => { + preRequests.addPending({ + requestId: '1234', + url: 'foo', + method: 'GET', + headers: { + 'Service-Worker': 'script', + }, + resourceType: 'xhr', + originalResourceType: undefined, + documentURL: 'foo', + cdpRequestWillBeSentTimestamp: 1, + cdpRequestWillBeSentReceivedTimestamp: performance.now() + performance.timeOrigin + 10000, + }) + + preRequests.addPending({ + requestId: '1234', + url: 'foo', + method: 'GET', + headers: {}, + resourceType: 'xhr', + originalResourceType: undefined, + documentURL: 'foo', + cdpRequestWillBeSentTimestamp: 1, + cdpRequestWillBeSentReceivedTimestamp: performance.now() + performance.timeOrigin + 10000, + }) + + expectPendingCounts(0, 1) + }) + + it('properly ignores requests that are controlled by a service worker', () => { + const processBrowserPreRequestStub = sinon.stub(preRequests.serviceWorkerManager, 'processBrowserPreRequest') + const browserPreRequest = { + requestId: '1234', + url: 'foo', + method: 'GET', + headers: {}, + resourceType: 'xhr', + originalResourceType: undefined, + documentURL: 'foo', + cdpRequestWillBeSentTimestamp: 1, + cdpRequestWillBeSentReceivedTimestamp: performance.now() + performance.timeOrigin + 10000, + } + + processBrowserPreRequestStub.returns(true) + + preRequests.addPending(browserPreRequest as BrowserPreRequest) + + expectPendingCounts(0, 0) + + expect(processBrowserPreRequestStub).to.be.calledWith(browserPreRequest) + }) + + it('processes service worker registration updated events', () => { + const registerServiceWorkerStub = sinon.stub(preRequests.serviceWorkerManager, 'registerServiceWorker') + const unregisterServiceWorkerStub = sinon.stub(preRequests.serviceWorkerManager, 'unregisterServiceWorker') + const registrations = [{ + registrationId: '1234', + scopeURL: 'foo', + isDeleted: false, + }, { + registrationId: '1235', + scopeURL: 'bar', + isDeleted: true, + }] + + preRequests.updateServiceWorkerRegistrations({ + registrations, + }) + + expect(registerServiceWorkerStub).to.be.calledWith({ + registrationId: '1234', + scopeURL: 'foo', + }) + + expect(unregisterServiceWorkerStub).to.be.calledWith({ + registrationId: '1235', + }) + }) + + it('processes service worker version updated events', () => { + const addActivatedServiceWorkerStub = sinon.stub(preRequests.serviceWorkerManager, 'addActivatedServiceWorker') + const versions = [{ + versionId: '1234', + registrationId: '1234', + scriptURL: 'foo', + runningStatus: 'stopped', + status: 'activating', + }, { + versionId: '1235', + registrationId: '1235', + scriptURL: 'bar', + runningStatus: 'running', + status: 'activated', + }] + + preRequests.updateServiceWorkerVersions({ + versions, + } as any) + + expect(addActivatedServiceWorkerStub).to.be.calledWith({ + registrationId: '1235', + scriptURL: 'bar', + }) + + expect(addActivatedServiceWorkerStub).not.to.be.calledWith({ + registrationId: '1234', + scriptURL: 'foo', + }) + }) + + it('processes service worker client side registration updated events', () => { + const addInitiatorToServiceWorkerStub = sinon.stub(preRequests.serviceWorkerManager, 'addInitiatorToServiceWorker') + const registration = { + scriptURL: 'foo', + initiatorURL: 'bar', + } + + preRequests.updateServiceWorkerClientSideRegistrations(registration) + + expect(addInitiatorToServiceWorkerStub).to.be.calledWith(registration) + }) }) diff --git a/packages/proxy/test/unit/http/util/service-worker-manager.spec.ts b/packages/proxy/test/unit/http/util/service-worker-manager.spec.ts new file mode 100644 index 000000000000..2c1baa46ec0c --- /dev/null +++ b/packages/proxy/test/unit/http/util/service-worker-manager.spec.ts @@ -0,0 +1,427 @@ +import { expect } from 'chai' +import { ServiceWorkerManager } from '../../../../lib/http/util/service-worker-manager' + +describe('lib/http/util/service-worker-manager', () => { + it('will detect when requests are controlled by a service worker', () => { + const manager = new ServiceWorkerManager() + + manager.registerServiceWorker({ + registrationId: '1', + scopeURL: 'http://localhost:8080', + }) + + manager.addActivatedServiceWorker({ + registrationId: '1', + scriptURL: 'http://localhost:8080/sw.js', + }) + + manager.addInitiatorToServiceWorker({ + scriptURL: 'http://localhost:8080/sw.js', + initiatorURL: 'http://localhost:8080/index.html', + }) + + // A script request emanated from the service worker's initiator is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-1', + method: 'GET', + url: 'http://localhost:8080/foo.js', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // A script request emanated from the previous script request is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-2', + method: 'GET', + url: 'http://example.com/bar.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + stack: { + callFrames: [{ + url: 'http://localhost:8080/foo.js', + lineNumber: 1, + columnNumber: 1, + functionName: '', + scriptId: '1', + }], + }, + }, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // A script request emanated from the previous css is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-3', + method: 'GET', + url: 'http://example.com/baz.woff2', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + url: 'http://example.com/bar.css', + }, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // A script request emanated from a different script request is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-4', + method: 'GET', + url: 'http://example.com/quux.js', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + stack: { + callFrames: [{ + url: 'http://example.com/bar.js', + lineNumber: 1, + columnNumber: 1, + functionName: '', + scriptId: '1', + }], + }, + }, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + + // A script request emanated from a different css request is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-5', + method: 'GET', + url: 'http://example.com/quux.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + url: 'http://example.com/baz.css', + }, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + + // A script request emanated from a different document is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-6', + method: 'GET', + url: 'http://example.com/quux.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + url: 'http://example.com/baz.css', + }, + documentURL: 'http://example.com/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + + // A preload request is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-7', + method: 'GET', + url: 'http://example.com/quux.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'preload', + }, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + }) + + it('will detect when requests are controlled by a service worker and handles query parameters', () => { + const manager = new ServiceWorkerManager() + + manager.registerServiceWorker({ + registrationId: '1', + scopeURL: 'http://localhost:8080', + }) + + manager.addActivatedServiceWorker({ + registrationId: '1', + scriptURL: 'http://localhost:8080/sw.js', + }) + + manager.addInitiatorToServiceWorker({ + scriptURL: 'http://localhost:8080/sw.js', + initiatorURL: 'http://localhost:8080/index.html', + }) + + // A script request emanated from the service worker's initiator is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-1', + method: 'GET', + url: 'http://localhost:8080/foo.js', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + documentURL: 'http://localhost:8080/index.html?foo=bar', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // A script request emanated from the previous script request is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-2', + method: 'GET', + url: 'http://example.com/bar.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + stack: { + callFrames: [{ + url: 'http://localhost:8080/foo.js?foo=bar', + lineNumber: 1, + columnNumber: 1, + functionName: '', + scriptId: '1', + }], + }, + }, + documentURL: 'http://localhost:8080/index.html?foo=bar', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // A script request emanated from the previous css is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-3', + method: 'GET', + url: 'http://example.com/baz.woff2', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + url: 'http://example.com/bar.css?foo=bar', + }, + documentURL: 'http://localhost:8080/index.html?foo=bar', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // A script request emanated from a different script request is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-4', + method: 'GET', + url: 'http://example.com/quux.js', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + stack: { + callFrames: [{ + url: 'http://example.com/bar.js?foo=bar', + lineNumber: 1, + columnNumber: 1, + functionName: '', + scriptId: '1', + }], + }, + }, + documentURL: 'http://localhost:8080/index.html?foo=bar', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + + // A script request emanated from a different css request is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-5', + method: 'GET', + url: 'http://example.com/quux.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + url: 'http://example.com/baz.css?foo=bar', + }, + documentURL: 'http://localhost:8080/index.html?foo=bar', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + + // A script request emanated from a different document is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-6', + method: 'GET', + url: 'http://example.com/quux.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + url: 'http://example.com/baz.css?foo=bar', + }, + documentURL: 'http://example.com/index.html?foo=bar', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + + // A preload request is not controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-7', + method: 'GET', + url: 'http://example.com/quux.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'preload', + }, + documentURL: 'http://localhost:8080/index.html?foo=bar', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + }) + + it('will detect when requests are controlled by a service worker and handles re-registrations', () => { + const manager = new ServiceWorkerManager() + + manager.registerServiceWorker({ + registrationId: '1', + scopeURL: 'http://localhost:8080', + }) + + manager.addActivatedServiceWorker({ + registrationId: '1', + scriptURL: 'http://localhost:8080/sw.js', + }) + + manager.addInitiatorToServiceWorker({ + scriptURL: 'http://localhost:8080/sw.js', + initiatorURL: 'http://localhost:8080/index.html', + }) + + // A script request emanated from the service worker's initiator is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-1', + method: 'GET', + url: 'http://localhost:8080/foo.js', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // This registration shouldn't wipe out the previous one + manager.registerServiceWorker({ + registrationId: '1', + scopeURL: 'http://localhost:8080', + }) + + // A script request emanated from the previous script request is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-2', + method: 'GET', + url: 'http://example.com/bar.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + stack: { + callFrames: [{ + url: 'http://localhost:8080/foo.js', + lineNumber: 1, + columnNumber: 1, + functionName: '', + scriptId: '1', + }], + }, + }, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + }) + + it('will detect when requests are controlled by a service worker and handles unregistrations', () => { + const manager = new ServiceWorkerManager() + + manager.registerServiceWorker({ + registrationId: '1', + scopeURL: 'http://localhost:8080', + }) + + manager.addActivatedServiceWorker({ + registrationId: '1', + scriptURL: 'http://localhost:8080/sw.js', + }) + + manager.addInitiatorToServiceWorker({ + scriptURL: 'http://localhost:8080/sw.js', + initiatorURL: 'http://localhost:8080/index.html', + }) + + // A script request emanated from the service worker's initiator is controlled + expect(manager.processBrowserPreRequest({ + requestId: 'id-1', + method: 'GET', + url: 'http://localhost:8080/foo.js', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.true + + // This registration shouldn't wipe out the previous one + manager.unregisterServiceWorker({ + registrationId: '1', + }) + + // A script request emanated from the previous script request is not controlled since the service worker was unregistered + expect(manager.processBrowserPreRequest({ + requestId: 'id-2', + method: 'GET', + url: 'http://example.com/bar.css', + headers: {}, + resourceType: 'fetch', + originalResourceType: undefined, + initiator: { + type: 'script', + stack: { + callFrames: [{ + url: 'http://localhost:8080/foo.js', + lineNumber: 1, + columnNumber: 1, + functionName: '', + scriptId: '1', + }], + }, + }, + documentURL: 'http://localhost:8080/index.html', + cdpRequestWillBeSentTimestamp: 0, + cdpRequestWillBeSentReceivedTimestamp: 0, + })).to.be.false + }) +}) diff --git a/packages/server/lib/automation/automation.ts b/packages/server/lib/automation/automation.ts index 22b2abfaa15f..fbae807a39c3 100644 --- a/packages/server/lib/automation/automation.ts +++ b/packages/server/lib/automation/automation.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid' import { Cookies } from './cookies' import { Screenshot } from './screenshot' import type { BrowserPreRequest } from '@packages/proxy' -import type { AutomationMiddleware, OnRequestEvent } from '@packages/types' +import type { AutomationMiddleware, OnRequestEvent, OnServiceWorkerClientSideRegistrationUpdated, OnServiceWorkerRegistrationUpdated, OnServiceWorkerVersionUpdated } from '@packages/types' import { cookieJar } from '../util/cookies' export type OnBrowserPreRequest = (browserPreRequest: BrowserPreRequest) => void @@ -14,7 +14,7 @@ export class Automation { private cookies: Cookies private screenshot: { capture: (data: any, automate: any) => any } - constructor (cyNamespace?: string, cookieNamespace?: string, screenshotsFolder?: string | false, public onBrowserPreRequest?: OnBrowserPreRequest, public onRequestEvent?: OnRequestEvent, public onRequestServedFromCache?: (requestId: string) => void, public onRequestFailed?: (requestId: string) => void, public onDownloadLinkClicked?: (downloadUrl: string) => void) { + constructor (cyNamespace?: string, cookieNamespace?: string, screenshotsFolder?: string | false, public onBrowserPreRequest?: OnBrowserPreRequest, public onRequestEvent?: OnRequestEvent, public onRequestServedFromCache?: (requestId: string) => void, public onRequestFailed?: (requestId: string) => void, public onDownloadLinkClicked?: (downloadUrl: string) => void, public onServiceWorkerRegistrationUpdated?: OnServiceWorkerRegistrationUpdated, public onServiceWorkerVersionUpdated?: OnServiceWorkerVersionUpdated, public onServiceWorkerClientSideRegistrationUpdated?: OnServiceWorkerClientSideRegistrationUpdated) { this.requests = {} // set the middleware diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index ff0992a284a5..05f11588b0db 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -173,6 +173,8 @@ export class CdpAutomation implements CDPClient { onFn('Network.responseReceived', this.onResponseReceived) onFn('Network.requestServedFromCache', this.onRequestServedFromCache) onFn('Network.loadingFailed', this.onRequestFailed) + onFn('ServiceWorker.workerRegistrationUpdated', this.onWorkerRegistrationUpdated) + onFn('ServiceWorker.workerVersionUpdated', this.onWorkerVersionUpdated) this.on = onFn this.off = offFn @@ -199,12 +201,14 @@ export class CdpAutomation implements CDPClient { const cdpAutomation = new CdpAutomation(sendDebuggerCommandFn, onFn, offFn, sendCloseCommandFn, automation) await sendDebuggerCommandFn('Network.enable', protocolManager?.networkEnableOptions ?? DEFAULT_NETWORK_ENABLE_OPTIONS) + await sendDebuggerCommandFn('ServiceWorker.enable') return cdpAutomation } private onNetworkRequestWillBeSent = (params: Protocol.Network.RequestWillBeSentEvent) => { debugVerbose('received networkRequestWillBeSent %o', params) + let url = params.request.url // in Firefox, the hash is incorrectly included in the URL: https://bugzilla.mozilla.org/show_bug.cgi?id=1715366 @@ -228,6 +232,8 @@ export class CdpAutomation implements CDPClient { headers: params.request.headers, resourceType: normalizeResourceType(params.type), originalResourceType: params.type, + initiator: params.initiator, + documentURL: params.documentURL, // wallTime is in seconds: https://vanilla.aslushnikov.com/?Network.TimeSinceEpoch // normalize to milliseconds to be comparable to everything else we're gathering cdpRequestWillBeSentTimestamp: params.wallTime * 1000, @@ -246,7 +252,7 @@ export class CdpAutomation implements CDPClient { } private onResponseReceived = (params: Protocol.Network.ResponseReceivedEvent) => { - if (params.response.fromDiskCache) { + if (params.response.fromDiskCache || (params.response.fromServiceWorker && params.response.encodedDataLength <= 0)) { this.automation.onRequestServedFromCache?.(params.requestId) return @@ -261,6 +267,14 @@ export class CdpAutomation implements CDPClient { this.automation.onRequestEvent?.('response:received', browserResponseReceived) } + private onWorkerRegistrationUpdated = (params: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) => { + this.automation.onServiceWorkerRegistrationUpdated?.(params) + } + + private onWorkerVersionUpdated = (params: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) => { + this.automation.onServiceWorkerVersionUpdated?.(params) + } + private getAllCookies = (filter: CyCookieFilter) => { return this.sendDebuggerCommandFn('Network.getAllCookies') .then((result: Protocol.Network.GetAllCookiesResponse) => { diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 57e9c7187963..6c5b1db39b47 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -537,7 +537,7 @@ export = { await Promise.all([ options.videoApi && this._recordVideo(cdpAutomation, options.videoApi, Number(options.browser.majorVersion)), this._handleDownloads(pageCriClient, options.downloadsFolder, automation), - utils.handleDownloadLinksViaCDP(pageCriClient, automation), + utils.initializeCDP(pageCriClient, automation), ]) await this._navigateUsingCRI(pageCriClient, url) diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index b47b80e33fb1..139713c21347 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -307,7 +307,7 @@ export = { cdpSocketServer?.attachCDPClient(cdpAutomation), videoApi && recordVideo(cdpAutomation, videoApi), this._handleDownloads(win, options.downloadsFolder, automation), - utils.handleDownloadLinksViaCDP(pageCriClient, automation), + utils.initializeCDP(pageCriClient, automation), // Ensure to clear browser state in between runs. This is handled differently in browsers when we launch new tabs, but we don't have that concept in electron pageCriClient.send('Storage.clearDataForOrigin', { origin: '*', storageTypes: 'all' }), pageCriClient.send('Network.clearBrowserCache'), diff --git a/packages/server/lib/browsers/utils.ts b/packages/server/lib/browsers/utils.ts index deed00e225cc..12e8f6c93a82 100644 --- a/packages/server/lib/browsers/utils.ts +++ b/packages/server/lib/browsers/utils.ts @@ -15,7 +15,7 @@ declare global { navigation?: { addEventListener: (event: string, listener: (event: any) => void) => void } - cypressDownloadLinkClicked: (url: string) => void + cypressUtilityBinding?: (url: string) => void } } @@ -398,46 +398,99 @@ const throwBrowserNotFound = function (browserName, browsers: FoundBrowser[] = [ // Chromium browsers and webkit do not give us pre requests for download links but they still go through the proxy. // We need to notify the proxy when they are clicked so that we can resolve the pending request waiting to be // correlated in the proxy. -const handleDownloadLinksViaCDP = async (criClient: CriClient, automation: Automation) => { +const initializeCDP = async (criClient: CriClient, automation: Automation) => { await criClient.send('Runtime.enable') await criClient.send('Runtime.addBinding', { - name: 'cypressDownloadLinkClicked', + name: 'cypressUtilityBinding', }) await criClient.on('Runtime.bindingCalled', async (data) => { - if (data.name === 'cypressDownloadLinkClicked') { - const url = data.payload - - await automation.onDownloadLinkClicked?.(url) + if (data.name === 'cypressUtilityBinding') { + const event = JSON.parse(data.payload) + + switch (event.type) { + case 'service-worker-registration': + await automation.onServiceWorkerClientSideRegistrationUpdated?.(event) + break + case 'download': + await automation.onDownloadLinkClicked?.(event.destination) + break + default: + throw new Error(`Unknown cypressUtilityBinding event type: ${event.type}`) + } } }) await criClient.send('Page.addScriptToEvaluateOnNewDocument', { - source: `(${listenForDownload.toString()})()`, + source: ` + const binding = window['cypressUtilityBinding'] + delete window['cypressUtilityBinding'] + ;(${listenForDownload.toString()})(binding) + ;(${overrideServiceWorkerRegistration.toString()})(binding) + `, }) } +const overrideServiceWorkerRegistration = (binding) => { + const oldRegister = window.ServiceWorkerContainer.prototype.register + + window.ServiceWorkerContainer.prototype.register = function (scriptURL, options) { + const anchor = document.createElement('a') + + let resolvedHref: URL + + if (typeof scriptURL === 'string') { + anchor.setAttribute('href', scriptURL) + resolvedHref = new URL(anchor.href) + } else { + resolvedHref = scriptURL + } + + anchor.remove() + const resolvedUrl = `${resolvedHref.origin}${resolvedHref.pathname}` + + const serviceWorkerRegistrationEvent = { + type: 'service-worker-registration', + scriptURL: resolvedUrl, + initiatorURL: window.location.href, + } + + binding(JSON.stringify(serviceWorkerRegistrationEvent)) + + return oldRegister.apply(this, [scriptURL, options]) + } +} + // The most efficient way to do this is to listen for the navigate event. However, this is only available in chromium browsers (after 102). // For older versions and for webkit, we need to listen for click events on anchor tags with the download attribute. -const listenForDownload = () => { - if (window.navigation) { - window.navigation.addEventListener('navigate', (event) => { - if (typeof event.downloadRequest === 'string') { - window.cypressDownloadLinkClicked(event.destination.url) - } - }) - } else { - document.addEventListener('click', (event) => { - if (event.target instanceof HTMLAnchorElement && typeof event.target.download === 'string') { - window.cypressDownloadLinkClicked(event.target.href) - } - }) +const listenForDownload = (binding) => { + if (binding) { + const createDownloadEvent = (destination) => { + return JSON.stringify({ + type: 'download', + destination, + }) + } - document.addEventListener('keydown', (event) => { - if (event.target instanceof HTMLAnchorElement && event.key === 'Enter' && typeof event.target.download === 'string') { - window.cypressDownloadLinkClicked(event.target.href) - } - }) + if (window.navigation) { + window.navigation.addEventListener('navigate', (event) => { + if (typeof event.downloadRequest === 'string') { + binding(createDownloadEvent(event.destination.url)) + } + }) + } else { + document.addEventListener('click', (event) => { + if (event.target instanceof HTMLAnchorElement && typeof event.target.download === 'string') { + binding(createDownloadEvent(event.target.href)) + } + }) + + document.addEventListener('keydown', (event) => { + if (event.target instanceof HTMLAnchorElement && event.key === 'Enter' && typeof event.target.download === 'string') { + binding(createDownloadEvent(event.target.href)) + } + }) + } } } @@ -477,7 +530,7 @@ export = { throwBrowserNotFound, - handleDownloadLinksViaCDP, + initializeCDP, listenForDownload, diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index d06ea86311b3..da30566384ae 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -231,6 +231,7 @@ export class WebKitAutomation { headers: request.headers(), resourceType: normalizeResourceType(request.resourceType()), originalResourceType: request.resourceType(), + documentURL: request.frame().url(), cdpRequestWillBeSentTimestamp: request.timing().requestStart, cdpRequestWillBeSentReceivedTimestamp: performance.now() + performance.timeOrigin, } diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index d7df008084a9..c8a19cf0aa4b 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -23,6 +23,7 @@ import { DataContext, getCtx } from '@packages/data-context' import { createHmac } from 'crypto' import type ProtocolManager from './cloud/protocol' import { ServerBase } from './server-base' +import type Protocol from 'devtools-protocol' export interface Cfg extends ReceivedCypressOptions { projectId?: string @@ -342,7 +343,19 @@ export class ProjectBase extends EE { this.server.addPendingUrlWithoutPreRequest(downloadUrl) } - this._automation = new Automation(namespace, socketIoCookie, screenshotsFolder, onBrowserPreRequest, onRequestEvent, onRequestServedFromCache, onRequestFailed, onDownloadLinkClicked) + const onServiceWorkerRegistrationUpdated = (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) => { + this.server.updateServiceWorkerRegistrations(data) + } + + const onServiceWorkerVersionUpdated = (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) => { + this.server.updateServiceWorkerVersions(data) + } + + const onServiceWorkerClientSideRegistrationUpdated = (data: { scriptURL: string, initiatorURL: string }) => { + this.server.updateServiceWorkerClientSideRegistrations(data) + } + + this._automation = new Automation(namespace, socketIoCookie, screenshotsFolder, onBrowserPreRequest, onRequestEvent, onRequestServedFromCache, onRequestFailed, onDownloadLinkClicked, onServiceWorkerRegistrationUpdated, onServiceWorkerVersionUpdated, onServiceWorkerClientSideRegistrationUpdated) const ios = this.server.startWebsockets(this.automation, this.cfg, { onReloadBrowser: options.onReloadBrowser, diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 6c36769ac8e6..0c292f56e084 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -40,6 +40,7 @@ import statusCode from './util/status_code' import headersUtil from './util/headers' import stream from 'stream' import isHtml from 'is-html' +import type Protocol from 'devtools-protocol' const debug = Debug('cypress:server:server-base') @@ -507,6 +508,18 @@ export class ServerBase { this.networkProxy.addPendingUrlWithoutPreRequest(downloadUrl) } + updateServiceWorkerRegistrations (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) { + this.networkProxy.updateServiceWorkerRegistrations(data) + } + + updateServiceWorkerVersions (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) { + this.networkProxy.updateServiceWorkerVersions(data) + } + + updateServiceWorkerClientSideRegistrations (data: { scriptURL: string, initiatorURL: string }) { + this.networkProxy.updateServiceWorkerClientSideRegistrations(data) + } + _createHttpServer (app): DestroyableHttpServer { const svr = http.createServer(httpUtils.lenientOptions, app) @@ -617,7 +630,7 @@ export class ServerBase { } reset () { - this._networkProxy?.reset() + this._networkProxy?.reset({ fullReset: true }) this.resourceTypeAndCredentialManager.clear() const baseUrl = this._baseUrl ?? '' diff --git a/packages/server/test/unit/browsers/cdp_automation_spec.ts b/packages/server/test/unit/browsers/cdp_automation_spec.ts index b02d6449ec6f..b849d0e723e2 100644 --- a/packages/server/test/unit/browsers/cdp_automation_spec.ts +++ b/packages/server/test/unit/browsers/cdp_automation_spec.ts @@ -27,11 +27,13 @@ context('lib/browsers/cdp_automation', () => { networkEnableOptions: enabledObject, } as ProtocolManagerShape - const localCommandStub = localCommand.withArgs('Network.enable', enabledObject).resolves() + const localNetworkCommandStub = localCommand.withArgs('Network.enable', enabledObject).resolves() + const localServiceWorkerCommandStub = localCommand.withArgs('ServiceWorker.enable').resolves() await CdpAutomation.create(localCommand, localOnFn, localOffFn, localSendCloseTargetCommand, localAutomation as any, localManager) - expect(localCommandStub).to.have.been.calledWith('Network.enable', enabledObject) + expect(localNetworkCommandStub).to.have.been.calledWith('Network.enable', enabledObject) + expect(localServiceWorkerCommandStub).to.have.been.calledWith('ServiceWorker.enable') }) it('networkEnabledOptions - protocol disabled', async function () { @@ -74,6 +76,8 @@ context('lib/browsers/cdp_automation', () => { onRequestEvent: sinon.stub(), onRequestServedFromCache: sinon.stub(), onRequestFailed: sinon.stub(), + onServiceWorkerRegistrationUpdated: sinon.stub(), + onServiceWorkerVersionUpdated: sinon.stub(), } cdpAutomation = await CdpAutomation.create(this.sendDebuggerCommand, this.onFn, this.offFn, this.sendCloseTargetCommand, this.automation) @@ -190,6 +194,30 @@ context('lib/browsers/cdp_automation', () => { ) }) + it('triggers onRequestEvent when response is cached from service worker but data length is > 0', function () { + const browserResponseReceived = { + requestId: '0', + response: { + status: 200, + headers: {}, + fromServiceWorker: true, + encodedDataLength: 1, + }, + } + + this.onFn + .withArgs('Network.responseReceived') + .yield(browserResponseReceived) + + expect(this.automation.onRequestEvent).to.have.been.calledWith( + 'response:received', { + requestId: browserResponseReceived.requestId, + status: browserResponseReceived.response.status, + headers: browserResponseReceived.response.headers, + }, + ) + }) + it('cleans up prerequests when response is cached from disk', function () { const browserResponseReceived = { requestId: '0', @@ -206,6 +234,24 @@ context('lib/browsers/cdp_automation', () => { expect(this.automation.onRequestEvent).not.to.have.been.called }) + + it('cleans up prerequests when response is cached from service worker and data length is <= 0', function () { + const browserResponseReceived = { + requestId: '0', + response: { + status: 200, + headers: {}, + fromServiceWorker: true, + encodedDataLength: -1, + }, + } + + this.onFn + .withArgs('Network.responseReceived') + .yield(browserResponseReceived) + + expect(this.automation.onRequestEvent).not.to.have.been.called + }) }) describe('.onRequestServedFromCache', function () { @@ -236,6 +282,41 @@ context('lib/browsers/cdp_automation', () => { }) }) + describe('.onWorkerRegistrationUpdated', function () { + it('triggers onServiceWorkerRegistrationUpdated', function () { + const browserWorkerRegistrationUpdated = { + registrations: [{ + registrationId: '0', + scopeURL: 'https://www.google.com', + }], + } + + this.onFn + .withArgs('ServiceWorker.workerRegistrationUpdated') + .yield(browserWorkerRegistrationUpdated) + + expect(this.automation.onServiceWorkerRegistrationUpdated).to.have.been.calledWith(browserWorkerRegistrationUpdated) + }) + }) + + describe('.onWorkerVersionUpdated', function () { + it('triggers onServiceWorkerVersionUpdated', function () { + const browserWorkerVersionUpdated = { + versions: [{ + registrationId: '0', + versionId: '1', + scriptURL: 'https://www.google.com', + }], + } + + this.onFn + .withArgs('ServiceWorker.workerVersionUpdated') + .yield(browserWorkerVersionUpdated) + + expect(this.automation.onServiceWorkerVersionUpdated).to.have.been.calledWith(browserWorkerVersionUpdated) + }) + }) + describe('get:cookies', () => { beforeEach(function () { this.sendDebuggerCommand.withArgs('Network.getAllCookies') diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 5f8f5286c436..174455668bc5 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -61,7 +61,7 @@ describe('lib/browsers/chrome', () => { sinon.stub(launch, 'launch').resolves(this.launchedBrowser) sinon.stub(utils, 'getProfileDir').returns('/profile/dir') sinon.stub(utils, 'ensureCleanCache').resolves('/profile/dir/CypressCache') - sinon.stub(utils, 'handleDownloadLinksViaCDP').resolves() + sinon.stub(utils, 'initializeCDP').resolves() this.readJson = sinon.stub(fs, 'readJson') this.readJson.withArgs('/profile/dir/Default/Preferences').rejects({ code: 'ENOENT' }) @@ -82,15 +82,16 @@ describe('lib/browsers/chrome', () => { .then(() => { expect(utils.getPort).to.have.been.calledOnce // to get remote interface port - expect(this.pageCriClient.send.callCount).to.equal(6) + expect(this.pageCriClient.send.callCount).to.equal(7) expect(this.pageCriClient.send).to.have.been.calledWith('Page.bringToFront') expect(this.pageCriClient.send).to.have.been.calledWith('Page.navigate') expect(this.pageCriClient.send).to.have.been.calledWith('Page.enable') expect(this.pageCriClient.send).to.have.been.calledWith('Page.setDownloadBehavior') expect(this.pageCriClient.send).to.have.been.calledWith('Network.enable') expect(this.pageCriClient.send).to.have.been.calledWith('Fetch.enable') + expect(this.pageCriClient.send).to.have.been.calledWith('ServiceWorker.enable') - expect(utils.handleDownloadLinksViaCDP).to.be.calledOnce + expect(utils.initializeCDP).to.be.calledOnce }) }) diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index 194e1414e076..3dc92fbd5f7d 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -68,7 +68,7 @@ describe('lib/browsers/electron', () => { sinon.stub(Windows, 'installExtension').returns() sinon.stub(Windows, 'removeAllExtensions').returns() sinon.stub(electronApp, 'getRemoteDebuggingPort').resolves(1234) - sinon.stub(utils, 'handleDownloadLinksViaCDP').resolves() + sinon.stub(utils, 'initializeCDP').resolves() // mock CRI client during testing this.pageCriClient = { @@ -396,7 +396,7 @@ describe('lib/browsers/electron', () => { it('handles download links via cdp', function () { return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { - expect(utils.handleDownloadLinksViaCDP).to.be.calledWith(this.pageCriClient, this.automation) + expect(utils.initializeCDP).to.be.calledWith(this.pageCriClient, this.automation) }) }) diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index 3bcea69f8615..f8ee092f9656 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -3,6 +3,7 @@ import type { ReceivedCypressOptions } from './config' import type { PlatformName } from './platform' import type { RunModeVideoApi } from './video' import type { ProtocolManagerShape } from './protocol' +import type Protocol from 'devtools-protocol' export type OpenProjectLaunchOpts = { projectRoot: string @@ -59,6 +60,12 @@ type NullableMiddlewareHook = (() => void) | null export type OnRequestEvent = (eventName: string, data: any) => void +export type OnServiceWorkerRegistrationUpdated = (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) => void + +export type OnServiceWorkerVersionUpdated = (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) => void + +export type OnServiceWorkerClientSideRegistrationUpdated = (data: { scriptURL: string, initiatorURL: string }) => void + export interface AutomationMiddleware { onPush?: NullableMiddlewareHook onBeforeRequest?: OnRequestEvent | null diff --git a/system-tests/lib/protocol-stubs/protocolStubResponse.ts b/system-tests/lib/protocol-stubs/protocolStubResponse.ts index 238816848ee1..f67433106aa3 100644 --- a/system-tests/lib/protocol-stubs/protocolStubResponse.ts +++ b/system-tests/lib/protocol-stubs/protocolStubResponse.ts @@ -50,3 +50,5 @@ export const PROTOCOL_STUB_NONFATAL_ERROR = stub('protocolStubWithNonFatalError. export const PROTOCOL_STUB_BEFORETEST_ERROR = stub('protocolStubWithBeforeTestError.ts') export const PROTOCOL_STUB_FONT_FLOODING = stub('protocolStubFontFlooding.ts') + +export const PROTOCOL_STUB_SERVICE_WORKER = stub('protocolStubServiceWorker.ts') diff --git a/system-tests/lib/protocol-stubs/protocolStubServiceWorker.ts b/system-tests/lib/protocol-stubs/protocolStubServiceWorker.ts new file mode 100644 index 000000000000..6c29162cb223 --- /dev/null +++ b/system-tests/lib/protocol-stubs/protocolStubServiceWorker.ts @@ -0,0 +1,114 @@ +import path from 'path' +import fs from 'fs-extra' +import type { AppCaptureProtocolInterface, ResponseEndedWithEmptyBodyOptions, ResponseStreamOptions, ResponseStreamTimedOutOptions } from '@packages/types' +import type { Readable } from 'stream' + +const getFilePath = (filename) => { + return path.join( + path.resolve(__dirname), + 'cypress', + 'system-tests-protocol-dbs', + `${filename}.json`, + ) +} + +type URLAndFrame = { + url: string + frameId: string +} + +export class AppCaptureProtocol implements AppCaptureProtocolInterface { + private filename: string + private events = { + numberOfResponseStreamReceivedEvents: 0, + correlatedUrls: {}, + } + private idToUrlAndFrameMap = new Map() + + getDbMetadata (): { offset: number, size: number } { + return { + offset: 0, + size: 0, + } + } + + responseStreamReceived (options: ResponseStreamOptions): Readable { + this.events.numberOfResponseStreamReceivedEvents += 1 + + const frameIds = this.events.correlatedUrls[this.idToUrlAndFrameMap[options.requestId].url] || [] + + if (!frameIds.includes(this.idToUrlAndFrameMap[options.requestId].frameId)) { + this.events.correlatedUrls[this.idToUrlAndFrameMap[options.requestId].url] = frameIds + } + + frameIds.push(this.idToUrlAndFrameMap[options.requestId].frameId) + + return options.responseStream + } + + connectToBrowser = async (cdpClient) => { + cdpClient.on('Network.requestWillBeSent', (event) => { + this.idToUrlAndFrameMap[event.requestId] = { + url: event.request.url, + frameId: event.frameId ? 'frame id' : 'no frame id', + } + }) + } + + addRunnables = (runnables) => { + return Promise.resolve() + } + + beforeSpec = ({ archivePath, db }) => { + this.filename = getFilePath(path.basename(db.name)) + + if (!fs.existsSync(archivePath)) { + // If a dummy file hasn't been created by the test, write a tar file so that it can be fake uploaded + fs.writeFileSync(archivePath, '') + } + } + + async afterSpec (): Promise { + try { + fs.outputFileSync(this.filename, JSON.stringify(this.events, null, 2)) + } catch (e) { + console.log('error writing protocol events', e) + } + } + + beforeTest = (test) => { + return Promise.resolve() + } + + commandLogAdded = (log) => { + } + + commandLogChanged = (log) => { + } + + viewportChanged = (input) => { + } + + urlChanged = (input) => { + } + + pageLoading = (input) => { + } + + preAfterTest = (test, options) => { + return Promise.resolve() + } + + afterTest = (test) => { + return Promise.resolve() + } + + responseEndedWithEmptyBody = (options: ResponseEndedWithEmptyBodyOptions) => { + } + + responseStreamTimedOut (options: ResponseStreamTimedOutOptions): void { + } + + resetTest (testId: string): void { + } +} diff --git a/system-tests/projects/e2e/cypress/e2e/service_worker.cy.js b/system-tests/projects/e2e/cypress/e2e/service_worker.cy.js index 75bb104cfd93..0b33d0689bea 100644 --- a/system-tests/projects/e2e/cypress/e2e/service_worker.cy.js +++ b/system-tests/projects/e2e/cypress/e2e/service_worker.cy.js @@ -1,10 +1,14 @@ const swReq = (win) => { - return win.navigator?.serviceWorker?.ready.then(() => win) + return new Promise((resolve) => { + win.addEventListener('service-worker:ready', () => { + resolve(win) + }) + }) } // Timeout of 1500 will ensure that the proxy correlation timeout is not hit it('loads service worker', { defaultCommandTimeout: 1500 }, () => { - cy.visit('https://localhost:1515/service_worker.html') + cy.visit('https://localhost:1515/service-worker-assets/scope/service_worker.html') .then(swReq) }) @@ -13,6 +17,6 @@ it('loads service worker', { defaultCommandTimeout: 1500 }, () => { // cache that have different headers that need to be tested in the proxy. // Timeout of 1500 will ensure that the proxy correlation timeout is not hit it('loads service worker', { defaultCommandTimeout: 1500 }, () => { - cy.visit('https://localhost:1515/service_worker.html') + cy.visit('https://localhost:1515/service-worker-assets/scope/service_worker.html') .then(swReq) }) diff --git a/system-tests/projects/e2e/service-worker-assets/example.json b/system-tests/projects/e2e/service-worker-assets/example.json new file mode 100644 index 000000000000..b42f309e7ae5 --- /dev/null +++ b/system-tests/projects/e2e/service-worker-assets/example.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} \ No newline at end of file diff --git a/system-tests/projects/e2e/service-worker-assets/scope/load.js b/system-tests/projects/e2e/service-worker-assets/scope/load.js new file mode 100644 index 000000000000..1ebdbc2000f7 --- /dev/null +++ b/system-tests/projects/e2e/service-worker-assets/scope/load.js @@ -0,0 +1,8 @@ +navigator.serviceWorker?.register('/service-worker-assets/scope/service-worker.js') + +navigator.serviceWorker?.ready.then(async () => { + await fetch('/service-worker-assets/example.json') + await fetch('/service-worker-assets/scope/cached-service-worker') + + window.dispatchEvent(new Event('service-worker:ready')) +}) diff --git a/system-tests/projects/e2e/service-worker-assets/scope/service-worker.js b/system-tests/projects/e2e/service-worker-assets/scope/service-worker.js new file mode 100644 index 000000000000..9250acb14e65 --- /dev/null +++ b/system-tests/projects/e2e/service-worker-assets/scope/service-worker.js @@ -0,0 +1,28 @@ +const activate = async () => { + self.clients.claim() + if (self.registration.navigationPreload) { + await self.registration.navigationPreload.enable() + } +} + +self.addEventListener('activate', (event) => { + event.waitUntil(activate()) +}) + +self.addEventListener('install', function (event) { + event.waitUntil( + caches.open('v1').then(function (cache) { + return cache.addAll([ + '/service-worker-assets/scope/cached-service-worker', + ]) + }), + ) +}) + +self.addEventListener('fetch', function (event) { + event.respondWith( + caches.match(event.request).then(function (response) { + return response || fetch(event.request) + }), + ) +}) diff --git a/system-tests/projects/e2e/service_worker.html b/system-tests/projects/e2e/service-worker-assets/scope/service_worker.html similarity index 61% rename from system-tests/projects/e2e/service_worker.html rename to system-tests/projects/e2e/service-worker-assets/scope/service_worker.html index 7c9a6a19ba92..57b91e7469c1 100644 --- a/system-tests/projects/e2e/service_worker.html +++ b/system-tests/projects/e2e/service-worker-assets/scope/service_worker.html @@ -3,9 +3,7 @@ - +

hi

diff --git a/system-tests/projects/protocol/cypress/e2e/service-worker.cy.js b/system-tests/projects/protocol/cypress/e2e/service-worker.cy.js new file mode 100644 index 000000000000..044f87eb316f --- /dev/null +++ b/system-tests/projects/protocol/cypress/e2e/service-worker.cy.js @@ -0,0 +1,22 @@ +const swReq = (win) => { + return new Promise((resolve) => { + win.addEventListener('service-worker:ready', () => { + resolve(win) + }) + }) +} + +// Timeout of 1500 will ensure that the proxy correlation timeout is not hit +it('loads service worker', { defaultCommandTimeout: 1500 }, () => { + cy.visit('cypress/fixtures/service-worker-assets/scope/service_worker.html') + .then(swReq) +}) + +// Load the service worker again to ensure that the service worker cache +// can be loaded properly. There are requests that are made with the +// cache that have different headers that need to be tested in the proxy. +// Timeout of 1500 will ensure that the proxy correlation timeout is not hit +it('loads service worker', { defaultCommandTimeout: 1500 }, () => { + cy.visit('cypress/fixtures/service-worker-assets/scope/service_worker.html') + .then(swReq) +}) diff --git a/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/example.json b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/example.json new file mode 100644 index 000000000000..b42f309e7ae5 --- /dev/null +++ b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/example.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} \ No newline at end of file diff --git a/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/cached-service-worker.json b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/cached-service-worker.json new file mode 100644 index 000000000000..f1d9e345e665 --- /dev/null +++ b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/cached-service-worker.json @@ -0,0 +1,3 @@ +{ + "foo": "baz" +} \ No newline at end of file diff --git a/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/load.js b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/load.js new file mode 100644 index 000000000000..e436969c5b4c --- /dev/null +++ b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/load.js @@ -0,0 +1,8 @@ +navigator.serviceWorker?.register(new URL('http://localhost:2121/cypress/fixtures/service-worker-assets/scope/service-worker.js')) + +navigator.serviceWorker?.ready.then(async () => { + await fetch('/cypress/fixtures/service-worker-assets/example.json') + await fetch('/cypress/fixtures/service-worker-assets/scope/cached-service-worker.json') + + window.dispatchEvent(new Event('service-worker:ready')) +}) diff --git a/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service-worker.js b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service-worker.js new file mode 100644 index 000000000000..333d170bdcfa --- /dev/null +++ b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service-worker.js @@ -0,0 +1,28 @@ +const activate = async () => { + self.clients.claim() + if (self.registration.navigationPreload) { + await self.registration.navigationPreload.enable() + } +} + +self.addEventListener('activate', (event) => { + event.waitUntil(activate()) +}) + +self.addEventListener('install', function (event) { + event.waitUntil( + caches.open('v1').then(function (cache) { + return cache.addAll([ + '/cypress/fixtures/service-worker-assets/scope/cached-service-worker.json', + ]) + }), + ) +}) + +self.addEventListener('fetch', function (event) { + event.respondWith( + caches.match(event.request).then(function (response) { + return response || fetch(event.request) + }), + ) +}) diff --git a/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service_worker.html b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service_worker.html new file mode 100644 index 000000000000..cd13ef264ae0 --- /dev/null +++ b/system-tests/projects/protocol/cypress/fixtures/service-worker-assets/scope/service_worker.html @@ -0,0 +1,11 @@ + + + + + + + + +

hi

+ + diff --git a/system-tests/test/service_worker_protocol_spec.js b/system-tests/test/service_worker_protocol_spec.js new file mode 100644 index 000000000000..9713bc1484ad --- /dev/null +++ b/system-tests/test/service_worker_protocol_spec.js @@ -0,0 +1,56 @@ +const fs = require('fs-extra') +const path = require('path') +const systemTests = require('../lib/system-tests').default +const Fixtures = require('../lib/fixtures') +const { + createRoutes, + setupStubbedServer, + enableCaptureProtocol, +} = require('../lib/serverStub') +const { PROTOCOL_STUB_SERVICE_WORKER } = require('../lib/protocol-stubs/protocolStubResponse') + +const getFilePath = (filename) => { + return path.join( + Fixtures.projectPath('protocol'), + 'cypress', + 'system-tests-protocol-dbs', + `${filename}.json`, + ) +} + +const BROWSERS = ['chrome', 'electron'] + +describe('capture-protocol', () => { + setupStubbedServer(createRoutes()) + enableCaptureProtocol(PROTOCOL_STUB_SERVICE_WORKER) + + describe('service worker', () => { + BROWSERS.forEach((browser) => { + it(`verifies the number of font requests is correct - ${browser}`, function () { + return systemTests.exec(this, { + key: 'f858a2bc-b469-4e48-be67-0876339ee7e1', + project: 'protocol', + spec: 'service-worker.cy.js', + record: true, + expectedExitCode: 0, + port: 2121, + browser, + config: { + hosts: { + '*foobar.com': '127.0.0.1', + }, + }, + }).then(() => { + const protocolEvents = fs.readFileSync(getFilePath('e9e81b5e-cc58-4026-b2ff-8ae3161435a6.db'), 'utf8') + + expect(JSON.parse(protocolEvents).correlatedUrls).to.eql({ + 'http://localhost:2121/cypress/fixtures/service-worker-assets/example.json': ['frame id'], + 'http://localhost:2121/cypress/fixtures/service-worker-assets/scope/cached-service-worker.json': ['no frame id'], + 'http://localhost:2121/cypress/fixtures/service-worker-assets/scope/load.js': ['frame id'], + 'http://localhost:2121/cypress/fixtures/service-worker-assets/scope/service_worker.html': ['frame id', 'no frame id', 'no frame id'], + }) + }) + }) + }) + }) +}) diff --git a/system-tests/test/service_worker_spec.js b/system-tests/test/service_worker_spec.js index 5d4906926f9f..6452e1c55bd0 100644 --- a/system-tests/test/service_worker_spec.js +++ b/system-tests/test/service_worker_spec.js @@ -12,7 +12,7 @@ const onServer = function (app) { maxAge: 3600000, })) - app.get('/cached-sw', (req, res) => { + app.get('/service-worker-assets/scope/cached-service-worker', (req, res) => { res.set({ 'Access-Control-Allow-Origin': '*', })