diff --git a/cli/schema/cypress.schema.json b/cli/schema/cypress.schema.json index 1d18748be48b..5b7251565274 100644 --- a/cli/schema/cypress.schema.json +++ b/cli/schema/cypress.schema.json @@ -256,7 +256,7 @@ "experimentalSessionAndOrigin": { "type": "boolean", "default": false, - "description": "Enables cross-origin and improved session support, including the `cy.origin` and `cy.session` commands." + "description": "Enables cross-origin and improved session support, including the `cy.origin` and `cy.session` commands. See https://on.cypress.io/origin and https://on.cypress.io/session." }, "experimentalSourceRewriting": { "type": "boolean", diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index b5842c1178b4..30e09a61bf1c 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -56,15 +56,6 @@ declare namespace Cypress { password: string } - interface RemoteState { - auth?: Auth - domainName: string - strategy: 'file' | 'http' - origin: string - fileServer: string | null - props: Record - } - interface Backend { /** * Firefox only: Force Cypress to run garbage collection routines. @@ -1430,7 +1421,7 @@ declare namespace Cypress { * cy.get('h1').should('equal', 'Example Domain') * }) */ - origin(urlOrDomain: string, fn: () => void): Chainable + origin(urlOrDomain: string, fn: () => void): Chainable /** * Enables running Cypress commands in a secondary origin. @@ -1441,9 +1432,9 @@ declare namespace Cypress { * expect(foo).to.equal('foo') * }) */ - origin(urlOrDomain: string, options: { + origin(urlOrDomain: string, options: { args: T - }, fn: (args: T) => void): Chainable + }, fn: (args: T) => void): Chainable /** * Get the parent DOM element of a set of DOM elements. @@ -2846,7 +2837,7 @@ declare namespace Cypress { */ experimentalInteractiveRunEvents: boolean /** - * Enables cross-origin and improved session support, including the `cy.origin` and `cy.session` commands. + * Enables cross-origin and improved session support, including the `cy.origin` and `cy.session` commands. See https://on.cypress.io/origin and https://on.cypress.io/session. * @default false */ experimentalSessionAndOrigin: boolean @@ -2981,7 +2972,6 @@ declare namespace Cypress { projectName: string projectRoot: string proxyUrl: string - remote: RemoteState report: boolean reporterRoute: string reporterUrl: string @@ -5766,7 +5756,8 @@ declare namespace Cypress { } interface LogConfig extends Timeoutable { - id: number + /** Unique id for the log, in the form of '-' */ + id: string /** The JQuery element for the command. This will highlight the command in the main window when debugging */ $el: JQuery /** The scope of the log entry. If child, will appear nested below parents, prefixed with '-' */ @@ -5779,7 +5770,7 @@ declare namespace Cypress { message: any /** Set to false if you want to control the finishing of the command in the log yourself */ autoEnd: boolean - /** Set to false if you want to control the finishing of the command in the log yourself */ + /** Set to true to immediately finish the log */ end: boolean /** Return an object that will be printed in the dev tools console */ consoleProps(): ObjectLike diff --git a/packages/driver/cypress/fixtures/auth/index.html b/packages/driver/cypress/fixtures/auth/index.html index 75d81cd652e7..bc7f2327ab10 100644 --- a/packages/driver/cypress/fixtures/auth/index.html +++ b/packages/driver/cypress/fixtures/auth/index.html @@ -70,8 +70,6 @@ } else { const token = JSON.parse(cypressAuthToken) - // ToDo, check for expiry maybe? - // If the token exists, hooray, give them a logout button to destroy the token and refresh. const tag = document.createElement("p"); const text = document.createTextNode(`Welcome ${token.body.username}`); diff --git a/packages/driver/cypress/integration/commands/navigation_spec.js b/packages/driver/cypress/integration/commands/navigation_spec.js index cf3a9b5c94ef..1b9a09669743 100644 --- a/packages/driver/cypress/integration/commands/navigation_spec.js +++ b/packages/driver/cypress/integration/commands/navigation_spec.js @@ -2344,9 +2344,9 @@ describe('src/cy/commands/navigation', () => { } cy.on('command:queue:before:end', () => { - // force us to become unstable immediately - // else the beforeunload event fires at the end - // of the tick which is too late + // force us to become unstable immediately + // else the beforeunload event fires at the end + // of the tick which is too late cy.isStable(false, 'testing') win.location.href = '/timeout?ms=100' diff --git a/packages/driver/cypress/integration/cypress/log_spec.js b/packages/driver/cypress/integration/cypress/log_spec.js index 956209d37b87..c534282bd670 100644 --- a/packages/driver/cypress/integration/cypress/log_spec.js +++ b/packages/driver/cypress/integration/cypress/log_spec.js @@ -91,7 +91,7 @@ describe('src/cypress/log', function () { expect(LogUtils.countLogsByTests(tests)).to.equal(6) }) - it('returns zero if there are no agents routes or commands', () => { + it('returns zero if there are no agents, routes, or commands', () => { const tests = { a: { notAThing: true, diff --git a/packages/driver/cypress/integration/dom/coordinates_spec.ts b/packages/driver/cypress/integration/dom/coordinates_spec.ts index 84483caf04a5..abcc50232e02 100644 --- a/packages/driver/cypress/integration/dom/coordinates_spec.ts +++ b/packages/driver/cypress/integration/dom/coordinates_spec.ts @@ -238,15 +238,18 @@ describe('src/dom/coordinates', () => { } it('returns true if parent is a window and not an iframe', () => { - const win = getWindowLikeObject() + const win = cy.state('window') expect(isAUTFrame(win)).to.be.true }) - it('returns true if parent is a window and getting its frameElement property throws an error', () => { + it('returns true if parent is a window and getting its frameElement property throws a cross-origin error', () => { const win = getWindowLikeObject() + const err = new Error('cross-origin error') + + err.name = 'SecurityError' - cy.stub($elements, 'getNativeProp').throws('cross-origin error') + cy.stub($elements, 'getNativeProp').throws(err) expect(isAUTFrame(win)).to.be.true }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_log.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_log.spec.ts index 5b8fa6ae2fce..63b2b283db7a 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_log.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_log.spec.ts @@ -16,7 +16,7 @@ context('cy.origin log', () => { }) it('logs in primary and secondary origins', () => { - cy.origin('http://foobar.com:3500', () => { + cy.origin('http://foobar.com:3500', () => { const afterLogAdded = new Promise((resolve) => { const listener = (attrs) => { if (attrs.message === 'test log in cy.origin') { diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts index 67f188e603e7..2def9b96919b 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts @@ -8,7 +8,11 @@ context('cy.origin waiting', () => { it('.wait()', () => { cy.origin('http://foobar.com:3500', () => { - cy.wait(500) + const delay = cy.spy(Cypress.Promise, 'delay') + + cy.wait(50).then(() => { + expect(delay).to.be.calledWith(50, 'wait') + }) }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/multi_domain_yield_spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/multi_domain_yield_spec.ts index cc3fd35ac5cc..ce229fe9a8f4 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/multi_domain_yield_spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/multi_domain_yield_spec.ts @@ -118,7 +118,7 @@ describe('cy.origin yields', () => { done() }) - cy.origin('http://foobar.com:3500', () => { + cy.origin('http://foobar.com:3500', () => { cy.get('[data-cy="dom-check"]') }) .then((subject) => subject.text()) @@ -134,7 +134,7 @@ describe('cy.origin yields', () => { done() }) - cy.origin('http://foobar.com:3500', () => { + cy.origin<{ key: Function }>('http://foobar.com:3500', () => { cy.wrap({ key: () => { return 'whoops' diff --git a/packages/driver/src/cy/commands/navigation.ts b/packages/driver/src/cy/commands/navigation.ts index 407e0aa76e90..1d560bca334e 100644 --- a/packages/driver/src/cy/commands/navigation.ts +++ b/packages/driver/src/cy/commands/navigation.ts @@ -13,7 +13,7 @@ import debugFn from 'debug' const debug = debugFn('cypress:driver:navigation') let id = null -let previousUrlVisited: LocationObject | undefined +let previouslyVisitedLocation: LocationObject | undefined let hasVisitedAboutBlank: boolean = false let currentlyVisitingAboutBlank: boolean = false let knownCommandCausedInstability: boolean = false @@ -30,7 +30,7 @@ const reset = (test: any = {}) => { // continuously reset this // before each test run! - previousUrlVisited = undefined + previouslyVisitedLocation = undefined // make sure we reset that we haven't // visited about blank again @@ -53,33 +53,7 @@ const timedOutWaitingForPageLoad = (ms, log) => { const anticipatedCrossOriginHref = cy.state('anticipatingCrossOriginResponse')?.href // Were we anticipating a cross origin page when we timed out? - if (anticipatedCrossOriginHref) { - // We remain in an anticipating state until either a load even happens or a timeout. - cy.isAnticipatingCrossOriginResponseFor(undefined) - - // By default origins is just this location. - let originPolicies = [$Location.create(location.href).originPolicy] - - const currentCommand = cy.queue.state('current') - - if (currentCommand?.get('name') === 'origin') { - // If the current command is a cy.origin command, we should have gotten a request on the origin it expects. - originPolicies = [cy.state('latestActiveOriginPolicy')] - } else if (Cypress.isCrossOriginSpecBridge && cy.queue.isOnLastCommand()) { - // If this is a cross origin spec bridge and we're on the last command, we should have gotten a request on the origin of one of the parents. - originPolicies = cy.state('parentOriginPolicies') - } - - $errUtils.throwErrByPath('navigation.cross_origin_load_timed_out', { - args: { - configFile: Cypress.config('configFile'), - ms, - crossOriginUrl: $Location.create(anticipatedCrossOriginHref), - originPolicies, - }, - onFail: log, - }) - } else { + if (!anticipatedCrossOriginHref) { $errUtils.throwErrByPath('navigation.timed_out', { args: { configFile: Cypress.config('configFile'), @@ -88,9 +62,35 @@ const timedOutWaitingForPageLoad = (ms, log) => { onFail: log, }) } + + // We remain in an anticipating state until either a load even happens or a timeout. + cy.isAnticipatingCrossOriginResponseFor(undefined) + + // By default origins is just this location. + let originPolicies = [$Location.create(location.href).originPolicy] + + const currentCommand = cy.queue.state('current') + + if (currentCommand?.get('name') === 'origin') { + // If the current command is a cy.origin command, we should have gotten a request on the origin it expects. + originPolicies = [cy.state('latestActiveOriginPolicy')] + } else if (Cypress.isCrossOriginSpecBridge && cy.queue.isOnLastCommand()) { + // If this is a cross origin spec bridge and we're on the last command, we should have gotten a request on the origin of one of the parents. + originPolicies = cy.state('parentOriginPolicies') + } + + $errUtils.throwErrByPath('navigation.cross_origin_load_timed_out', { + args: { + configFile: Cypress.config('configFile'), + ms, + crossOriginUrl: $Location.create(anticipatedCrossOriginHref), + originPolicies, + }, + onFail: log, + }) } -const cannotVisitDifferentOrigin = ({ remote, existing, originalUrl, previousUrlVisited, log, isCrossOriginSpecBridge = false }) => { +const cannotVisitDifferentOrigin = ({ remote, existing, originalUrl, previouslyVisitedLocation, log, isCrossOriginSpecBridge = false }) => { const differences: string[] = [] if (remote.protocol !== existing.protocol) { @@ -109,7 +109,7 @@ const cannotVisitDifferentOrigin = ({ remote, existing, originalUrl, previousUrl onFail: log, args: { differences: differences.join(', '), - previousUrl: previousUrlVisited, + previousUrl: previouslyVisitedLocation, attemptedUrl: remote, originalUrl, isCrossOriginSpecBridge, @@ -123,12 +123,12 @@ const cannotVisitDifferentOrigin = ({ remote, existing, originalUrl, previousUrl $errUtils.throwErrByPath('visit.cannot_visit_different_origin', errOpts) } -const cannotVisitPreviousOrigin = ({ remote, originalUrl, previousUrlVisited, log }) => { +const cannotVisitPreviousOrigin = ({ remote, originalUrl, previouslyVisitedLocation, log }) => { const errOpts = { onFail: log, args: { attemptedUrl: remote, - previousUrl: previousUrlVisited, + previousUrl: previouslyVisitedLocation, originalUrl, }, errProps: { @@ -434,9 +434,7 @@ const stabilityChanged = (Cypress, state, config, stable) => { } const onCrossOriginFailure = (err) => { - options._log.set('message', '--page loaded--').snapshot().end() - options._log.set('state', 'failed') - options._log.set('error', err) + options._log.set('message', '--page loaded--').snapshot().error(err) resolve() } @@ -526,6 +524,7 @@ type InvalidContentTypeError = Error & { interface InternalVisitOptions extends Partial { _log?: Log + hasAlreadyVisitedUrl: boolean } export default (Commands, Cypress, cy, state, config) => { @@ -852,7 +851,7 @@ export default (Commands, Cypress, cy, state, config) => { onLoad () {}, }) - options.hasAlreadyVisitedUrl = !!previousUrlVisited + options.hasAlreadyVisitedUrl = !!previouslyVisitedLocation if (!_.isUndefined(options.qs) && !_.isObject(options.qs)) { $errUtils.throwErrByPath('visit.invalid_qs', { args: { qs: String(options.qs) } }) @@ -1125,7 +1124,7 @@ export default (Commands, Cypress, cy, state, config) => { // if the origin currently matches // then go ahead and change the iframe's src if (remote.originPolicy === existing.originPolicy) { - previousUrlVisited = remote + previouslyVisitedLocation = remote url = $Location.fullyQualifyUrl(url) @@ -1138,10 +1137,10 @@ export default (Commands, Cypress, cy, state, config) => { // if we've already cy.visit'ed in the test and we are visiting a new origin, // throw an error, else we'd be in a endless loop, // we also need to disable retries to prevent the endless loop - if (previousUrlVisited) { + if (previouslyVisitedLocation) { $utils.getTestFromRunnable(state('runnable'))._retries = 0 - const params = { remote, existing, originalUrl, previousUrlVisited, log: options._log } + const params = { remote, existing, originalUrl, previouslyVisitedLocation, log: options._log } return cannotVisitDifferentOrigin(params) } @@ -1151,7 +1150,7 @@ export default (Commands, Cypress, cy, state, config) => { // origin which isn't allowed within a cy.origin block if (Cypress.isCrossOriginSpecBridge) { const existingAutOrigin = win ? $Location.create(win.location.href) : $Location.create(Cypress.state('currentActiveOriginPolicy')) - const params = { remote, existing, originalUrl, previousUrlVisited: existingAutOrigin, log: options._log, isCrossOriginSpecBridge: true, isPrimaryOrigin } + const params = { remote, existing, originalUrl, previouslyVisitedLocation: existingAutOrigin, log: options._log, isCrossOriginSpecBridge: true, isPrimaryOrigin } return isPrimaryOrigin ? cannotVisitPreviousOrigin(params) : cannotVisitDifferentOrigin(params) } @@ -1238,6 +1237,7 @@ export default (Commands, Cypress, cy, state, config) => { // not a network failure, and we should throw the original error if (err.isCallbackError || err.isCrossOrigin) { delete err.isCallbackError + delete err.isCrossOrigin throw err } diff --git a/packages/driver/src/cy/location.ts b/packages/driver/src/cy/location.ts index c7b3658db694..34b9d51dd158 100644 --- a/packages/driver/src/cy/location.ts +++ b/packages/driver/src/cy/location.ts @@ -15,7 +15,7 @@ export const create = (state) => ({ return location } catch (e) { // it is possible we do not have access to the location - // for example, if the app has redirected to a 2nd origin + // for example, if the app has redirected to a different origin return '' } }, diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index 132564db7f9c..ea9b091e295a 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -262,15 +262,15 @@ export class CommandQueue extends Queue { // @ts-ignore run () { const next = () => { - // start at 0 index if one is not already set - let index = this.state('index') || this.state('index', 0) - // bail if we've been told to abort in case // an old command continues to run after if (this.stopped) { return } + // start at 0 index if one is not already set + let index = this.state('index') || this.state('index', 0) + const command = this.at(index) // if the command should be skipped, just bail and increment index diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index d6f9c479d103..a0f3a9e5ab6d 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -125,7 +125,6 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert config: any Cypress: any Cookies: any - autoRun: boolean devices: { keyboard: Keyboard @@ -208,7 +207,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert private testConfigOverride: TestConfigOverride private commandFns: Record = {} - constructor (specWindow, Cypress, Cookies, state, config, autoRun = true) { + constructor (specWindow, Cypress, Cookies, state, config) { super() state('specWindow', specWindow) @@ -219,7 +218,6 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.config = config this.Cypress = Cypress this.Cookies = Cookies - this.autoRun = autoRun initVideoRecorder(Cypress) this.testConfigOverride = new TestConfigOverride() @@ -339,7 +337,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.ensureSubjectByType = ensures.ensureSubjectByType this.ensureRunnable = ensures.ensureRunnable - const snapshots = createSnapshots(jquery.$$, state) + const snapshots = createSnapshots(this.$$, state) this.createSnapshot = snapshots.createSnapshot this.detachDom = snapshots.detachDom @@ -476,10 +474,6 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert } initialize ($autIframe) { - const signalStable = () => { - this.isStable(true, 'load') - } - this.state('$autIframe', $autIframe) // dont need to worry about a try/catch here @@ -538,17 +532,15 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert cy.state('autOrigin', remoteLocation.originPolicy) this.Cypress.primaryOriginCommunicator.toAllSpecBridges('window:load', { url: remoteLocation.href }) - - signalStable() } catch (err: any) { // this catches errors thrown by user-registered event handlers // for `window:load`. this is used in the `catch` below so they // aren't mistaken as cross-origin errors err.isFromWindowLoadEvent = true - signalStable() - throw err + } finally { + this.isStable(true, 'load') } } catch (err: any) { if (err.isFromWindowLoadEvent) { @@ -723,9 +715,20 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert cy.warnMixingPromisesAndCommands() } - if (cy.autoRun) { - cy.queue.run() - } + cy.queue.run() + .then(() => { + const onQueueEnd = cy.state('onQueueEnd') + + if (onQueueEnd) { + onQueueEnd() + } + }) + .catch(() => { + // errors from the queue are propagated to cy.fail by the queue itself + // and can be safely ignored here. omitting this catch causes + // unhandled rejections to be logged because Bluebird sees a promise + // chain with no catch handler + }) } return chain diff --git a/packages/driver/src/dom/coordinates.ts b/packages/driver/src/dom/coordinates.ts index 371e108829f9..976f6665e9ef 100644 --- a/packages/driver/src/dom/coordinates.ts +++ b/packages/driver/src/dom/coordinates.ts @@ -25,6 +25,11 @@ const isAUTFrame = (win) => { // a cross-origin error, meaning this is the AUT // NOTE: this will need to be updated once we add support for // cross-origin iframes + if (err.name !== 'SecurityError') { + // re-throw any error that's not a cross-origin error + throw err + } + return true } } diff --git a/packages/driver/src/multi-domain/communicator.ts b/packages/driver/src/multi-domain/communicator.ts index c9f8cfa3e97f..0e98df5ee5ef 100644 --- a/packages/driver/src/multi-domain/communicator.ts +++ b/packages/driver/src/multi-domain/communicator.ts @@ -35,7 +35,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { onMessage ({ data, source }) { // check if message is cross origin and if so, feed the message into // the cross origin bus with args and strip prefix - if (data?.event?.includes(CROSS_ORIGIN_PREFIX)) { + if (data?.event?.startsWith(CROSS_ORIGIN_PREFIX)) { const messageName = data.event.replace(CROSS_ORIGIN_PREFIX, '') // NOTE: need a special case here for 'bridge:ready' @@ -77,7 +77,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { const preprocessedData = preprocessForSerialization(data) - // if user defined arguments are passed in, do NOT sanitize it. + // if user defined arguments are passed in, do NOT sanitize them. if (data?.args) { preprocessedData.args = data.args } @@ -96,7 +96,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { const preprocessedData = preprocessForSerialization(data) - // if user defined arguments are passed in, do NOT sanitize it. + // if user defined arguments are passed in, do NOT sanitize them. if (data?.args) { preprocessedData.args = data.args } diff --git a/packages/driver/src/multi-domain/domain_fn.ts b/packages/driver/src/multi-domain/domain_fn.ts index bc91f5c40cc4..fbb4416cb9d0 100644 --- a/packages/driver/src/multi-domain/domain_fn.ts +++ b/packages/driver/src/multi-domain/domain_fn.ts @@ -101,6 +101,16 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { syncConfigToCurrentOrigin(config) syncEnvToCurrentOrigin(env) + cy.state('onQueueEnd', () => { + queueFinished = true + setRunnableStateToPassed() + Cypress.specBridgeCommunicator.toPrimary('queue:finished', { + subject: cy.state('subject'), + }, { + syncGlobals: true, + }) + }) + cy.state('onFail', (err) => { setRunnableStateToPassed() if (queueFinished) { @@ -155,16 +165,5 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { return } - - cy.queue.run() - .then(() => { - queueFinished = true - setRunnableStateToPassed() - Cypress.specBridgeCommunicator.toPrimary('queue:finished', { - subject: cy.state('subject'), - }, { - syncGlobals: true, - }) - }) }) } diff --git a/packages/driver/src/util/deferred.ts b/packages/driver/src/util/deferred.ts deleted file mode 100644 index e15423d51560..000000000000 --- a/packages/driver/src/util/deferred.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Bluebird from 'bluebird' - -export interface Deferred { - promise: Bluebird - resolve: (thenableOrResult?: unknown) => void - reject: (thenableOrResult?: unknown) => void -} - -export const createDeferred = () => { - const deferred = {} as Deferred - - deferred.promise = new Bluebird((resolve, reject) => { - deferred.resolve = resolve - deferred.reject = reject - }) - - return deferred -} diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index 4c470a0f8b9a..e310a6481e9f 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -3,6 +3,7 @@ /// /// /// +/// interface InternalWindowLoadDetails { type: 'same:origin' | 'cross:origin' | 'cross:origin:failure' @@ -88,6 +89,13 @@ declare namespace Cypress { (k: 'document', v?: Document): Document (k: 'window', v?: Window): Window (k: 'logGroupIds', v?: Array): Array + (k: 'autOrigin', v?: string): string + (k: 'originCommandBaseUrl', v?: string): string + (k: 'currentActiveOriginPolicy', v?: string): string + (k: 'latestActiveOriginPolicy', v?: string): string + (k: 'duringUserTestExecution', v?: boolean): boolean + (k: 'onQueueEnd', v?: () => void): () => void + (k: 'onFail', v?: (err: Error) => void): (err: Error) => void (k: string, v?: any): any state: Cypress.state } diff --git a/packages/driver/types/remote-state.d.ts b/packages/driver/types/remote-state.d.ts new file mode 100644 index 000000000000..443f83a66d85 --- /dev/null +++ b/packages/driver/types/remote-state.d.ts @@ -0,0 +1,14 @@ +declare namespace Cypress { + interface RemoteState { + auth?: Auth + domainName: string + strategy: 'file' | 'http' + origin: string + fileServer: string | null + props: Record + } + + interface RuntimeConfigOptions { + remote: RemoteState + } +} diff --git a/packages/extension/app/background.js b/packages/extension/app/background.js index b63de47a06ce..9856bbc68370 100644 --- a/packages/extension/app/background.js +++ b/packages/extension/app/background.js @@ -1,4 +1,4 @@ -/* global window */ +const get = require('lodash/get') const map = require('lodash/map') const pick = require('lodash/pick') const once = require('lodash/once') @@ -19,9 +19,17 @@ const firstOrNull = (cookies) => { return cookies[0] != null ? cookies[0] : null } -const connect = function (host, path, extraOpts) { - const isChromeLike = !!window.chrome && !window.browser +const checkIfFirefox = async () => { + if (!browser || !get(browser, 'runtime.getBrowserInfo')) { + return false + } + + const { name } = await browser.runtime.getBrowserInfo() + return name === 'Firefox' +} + +const connect = function (host, path, extraOpts) { const listenToCookieChanges = once(() => { return browser.cookies.onChanged.addListener((info) => { if (info.cause !== 'overwrite') { @@ -122,14 +130,21 @@ const connect = function (host, path, extraOpts) { } }) - ws.on('connect', () => { + ws.on('automation:config', async (config) => { + const isFirefox = await checkIfFirefox() + listenToCookieChanges() - // chrome-like browsers use CDP instead - if (!isChromeLike) { + // Non-Firefox browsers use CDP for these instead + if (isFirefox) { listenToDownloads() - listenToOnBeforeHeaders() + + if (config.experimentalSessionAndOrigin) { + listenToOnBeforeHeaders() + } } + }) + ws.on('connect', () => { ws.emit('automation:client:connected') }) diff --git a/packages/extension/test/integration/background_spec.js b/packages/extension/test/integration/background_spec.js index 03b4062e76e1..1558e0a966ac 100644 --- a/packages/extension/test/integration/background_spec.js +++ b/packages/extension/test/integration/background_spec.js @@ -4,6 +4,7 @@ const http = require('http') const socket = require('@packages/socket') const Promise = require('bluebird') const mockRequire = require('mock-require') +const client = require('../../app/client') const browser = { cookies: { @@ -25,9 +26,7 @@ const browser = { windows: { getLastFocused () {}, }, - runtime: { - - }, + runtime: {}, tabs: { query () {}, executeScript () {}, @@ -116,18 +115,23 @@ describe('app/background', () => { this.httpSrv = http.createServer() this.server = socket.server(this.httpSrv, { path: '/__socket.io' }) - this.onConnect = (callback) => { - const client = background.connect(`http://localhost:${PORT}`, '/__socket.io') - - client.on('connect', _.once(() => { - callback(client) - })) + const ws = { + on: sinon.stub(), + emit: sinon.stub(), } - this.stubEmit = (callback) => { - this.onConnect((client) => { - client.emit = _.once(callback) - }) + sinon.stub(client, 'connect').returns(ws) + + browser.runtime.getBrowserInfo = sinon.stub().resolves({ name: 'Firefox' }), + + this.connect = async (options = {}) => { + const ws = background.connect(`http://localhost:${PORT}`, '/__socket.io') + + // skip 'connect' and 'automation:client:connected' and trigger + // the handler that kicks everything off + await ws.on.withArgs('automation:config').args[0][1](options) + + return ws } this.httpSrv.listen(PORT, done) @@ -142,72 +146,47 @@ describe('app/background', () => { }) context('.connect', () => { - it('can connect', function (done) { - this.server.on('connection', () => { - return done() - }) - - return background.connect(`http://localhost:${PORT}`, '/__socket.io') - }) + it('emits \'automation:client:connected\'', async function () { + const ws = background.connect(`http://localhost:${PORT}`, '/__socket.io') - it('emits \'automation:client:connected\'', (done) => { - const client = background.connect(`http://localhost:${PORT}`, '/__socket.io') + await ws.on.withArgs('connect').args[0][1]() - sinon.spy(client, 'emit') - - return client.on('connect', _.once(() => { - expect(client.emit).to.be.calledWith('automation:client:connected') - - return done() - })) + expect(ws.emit).to.be.calledWith('automation:client:connected') }) - it('listens to cookie changes', (done) => { + it('listens to cookie changes', async function () { const addListener = sinon.stub(browser.cookies.onChanged, 'addListener') - const client = background.connect(`http://localhost:${PORT}`, '/__socket.io') - return client.on('connect', _.once(() => { - expect(addListener).to.be.calledOnce + await this.connect() - return done() - })) + expect(addListener).to.be.calledOnce }) }) context('cookies', () => { - it('onChanged does not emit when cause is overwrite', function (done) { + it('onChanged does not emit when cause is overwrite', async function () { const addListener = sinon.stub(browser.cookies.onChanged, 'addListener') + const ws = await this.connect() + const fn = addListener.getCall(0).args[0] - this.onConnect((client) => { - sinon.spy(client, 'emit') - - const fn = addListener.getCall(0).args[0] - - fn({ cause: 'overwrite' }) + fn({ cause: 'overwrite' }) - expect(client.emit).not.to.be.calledWith('automation:push:request') - - done() - }) + expect(ws.emit).not.to.be.calledWith('automation:push:request') }) - it('onChanged emits automation:push:request change:cookie', function (done) { + it('onChanged emits automation:push:request change:cookie', async function () { const info = { cause: 'explicit', cookie: { name: 'foo', value: 'bar' } } - sinon.stub(browser.cookies.onChanged, 'addListener').yieldsAsync(info) + sinon.stub(browser.cookies.onChanged, 'addListener').yields(info) - this.stubEmit((req, msg, data) => { - expect(req).to.eq('automation:push:request') - expect(msg).to.eq('change:cookie') - expect(data).to.deep.eq(info) + const ws = await this.connect() - done() - }) + expect(ws.emit).to.be.calledWith('automation:push:request', 'change:cookie', info) }) }) context('downloads', () => { - it('onCreated emits automation:push:request create:download', function (done) { + it('onCreated emits automation:push:request create:download', async function () { const downloadItem = { id: '1', filename: '/path/to/download.csv', @@ -215,23 +194,19 @@ describe('app/background', () => { url: 'http://localhost:1234/download.csv', } - sinon.stub(browser.downloads.onCreated, 'addListener').yieldsAsync(downloadItem) + sinon.stub(browser.downloads.onCreated, 'addListener').yields(downloadItem) - this.stubEmit((req, msg, data) => { - expect(req).to.eq('automation:push:request') - expect(msg).to.eq('create:download') - expect(data).to.deep.eq({ - id: `${downloadItem.id}`, - filePath: downloadItem.filename, - mime: downloadItem.mime, - url: downloadItem.url, - }) + const ws = await this.connect() - done() + expect(ws.emit).to.be.calledWith('automation:push:request', 'create:download', { + id: `${downloadItem.id}`, + filePath: downloadItem.filename, + mime: downloadItem.mime, + url: downloadItem.url, }) }) - it('onChanged emits automation:push:request complete:download', function (done) { + it('onChanged emits automation:push:request complete:download', async function () { const downloadDelta = { id: '1', state: { @@ -239,34 +214,29 @@ describe('app/background', () => { }, } - sinon.stub(browser.downloads.onChanged, 'addListener').yieldsAsync(downloadDelta) + sinon.stub(browser.downloads.onChanged, 'addListener').yields(downloadDelta) - this.stubEmit((req, msg, data) => { - expect(req).to.eq('automation:push:request') - expect(msg).to.eq('complete:download') - expect(data).to.deep.eq({ id: `${downloadDelta.id}` }) + const ws = await this.connect() - done() + expect(ws.emit).to.be.calledWith('automation:push:request', 'complete:download', { + id: `${downloadDelta.id}`, }) }) - it('onChanged does not emit if state does not exist', function (done) { + it('onChanged does not emit if state does not exist', async function () { const downloadDelta = { id: '1', } const addListener = sinon.stub(browser.downloads.onChanged, 'addListener') - this.onConnect((client) => { - sinon.spy(client, 'emit') - addListener.getCall(0).args[0](downloadDelta) + const ws = await this.connect() - expect(client.emit).not.to.be.calledWith('automation:push:request') + addListener.getCall(0).args[0](downloadDelta) - done() - }) + expect(ws.emit).not.to.be.calledWith('automation:push:request') }) - it('onChanged does not emit if state.current is not "complete"', function (done) { + it('onChanged does not emit if state.current is not "complete"', async function () { const downloadDelta = { id: '1', state: { @@ -275,64 +245,68 @@ describe('app/background', () => { } const addListener = sinon.stub(browser.downloads.onChanged, 'addListener') - this.onConnect((client) => { - sinon.spy(client, 'emit') + const ws = await this.connect() - addListener.getCall(0).args[0](downloadDelta) + addListener.getCall(0).args[0](downloadDelta) - expect(client.emit).not.to.be.calledWith('automation:push:request') - - done() - }) + expect(ws.emit).not.to.be.calledWith('automation:push:request') }) - it('does not add downloads listener if in chrome-like browser', function (done) { - global.window.chrome = {} + it('does not add downloads listener if in non-Firefox browser', async function () { + browser.runtime.getBrowserInfo = undefined const onCreated = sinon.stub(browser.downloads.onCreated, 'addListener') const onChanged = sinon.stub(browser.downloads.onChanged, 'addListener') - this.onConnect(() => { - expect(onCreated).not.to.be.called - expect(onChanged).not.to.be.called + await this.connect() - done() - }) + expect(onCreated).not.to.be.called + expect(onChanged).not.to.be.called }) }) context('add header to aut iframe requests', () => { - it('does not add header if it is the top frame', function (done) { + const withExperimentalFlagOn = { + experimentalSessionAndOrigin: true, + } + + it('does not listen to `onBeforeSendHeaders` if experimental flag is off', async function () { + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect() + + expect(browser.webRequest.onBeforeSendHeaders.addListener).not.to.be.called + }) + + it('does not add header if it is the top frame', async function () { const details = { parentFrameId: -1, } sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - this.onConnect(() => { - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + await this.connect(withExperimentalFlagOn) - expect(result).to.be.undefined - done() - }) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.be.undefined }) - it('does not add header if it is a nested frame', function (done) { + it('does not add header if it is a nested frame', async function () { const details = { parentFrameId: 12345, } sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - this.onConnect(() => { - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + await this.connect(withExperimentalFlagOn) - expect(result).to.be.undefined - done() - }) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.be.undefined }) - it('does not add header if it is not a sub frame request', function (done) { + it('does not add header if it is not a sub frame request', async function () { const details = { parentFrameId: 0, type: 'stylesheet', @@ -340,15 +314,14 @@ describe('app/background', () => { sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - this.onConnect(() => { - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + await this.connect(withExperimentalFlagOn) - expect(result).to.be.undefined - done() - }) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.be.undefined }) - it('does not add header if it is a spec frame request', function (done) { + it('does not add header if it is a spec frame request', async function () { const details = { parentFrameId: 0, type: 'sub_frame', @@ -357,15 +330,13 @@ describe('app/background', () => { sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - this.onConnect(() => { - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + await this.connect(withExperimentalFlagOn) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - expect(result).to.be.undefined - done() - }) + expect(result).to.be.undefined }) - it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', function (done) { + it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', async function () { const details = { parentFrameId: 0, type: 'sub_frame', @@ -377,36 +348,31 @@ describe('app/background', () => { sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - this.onConnect(() => { - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - - expect(result).to.deep.equal({ - requestHeaders: [ - { - name: 'X-Foo', - value: 'Bar', - }, - { - name: 'X-Cypress-Is-AUT-Frame', - value: 'true', - }, - ], - }) + await this.connect(withExperimentalFlagOn) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - done() + expect(result).to.deep.equal({ + requestHeaders: [ + { + name: 'X-Foo', + value: 'Bar', + }, + { + name: 'X-Cypress-Is-AUT-Frame', + value: 'true', + }, + ], }) }) - it('does not add before-headers listener if in chrome-like browser', function (done) { - global.window.chrome = {} + it('does not add before-headers listener if in non-Firefox browser', async function () { + browser.runtime.getBrowserInfo = undefined const onBeforeSendHeaders = sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - this.onConnect(() => { - expect(onBeforeSendHeaders).not.to.be.called + await this.connect(withExperimentalFlagOn) - done() - }) + expect(onBeforeSendHeaders).not.to.be.called }) }) @@ -527,6 +493,9 @@ describe('app/background', () => { context('integration', () => { beforeEach(function (done) { done = _.once(done) + + client.connect.restore() + this.server.on('connection', (socket1) => { this.socket = socket1 diff --git a/packages/network/lib/index.ts b/packages/network/lib/index.ts index 19001918fa9b..90d94efa41cb 100644 --- a/packages/network/lib/index.ts +++ b/packages/network/lib/index.ts @@ -19,5 +19,3 @@ export { export { allowDestroy } from './allow-destroy' export { concatStream } from './concat-stream' - -export type { ParsedHost } from './types' diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 82697b51c37c..2455b4d0306c 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -413,7 +413,7 @@ interface EnsureSameSiteNoneProps { } const cookieSameSiteRegex = /SameSite=(\w+)/i -const cookieSecureRegex = /Secure/i +const cookieSecureRegex = /(^|\W)Secure(\W|$)/i const cookieSecureSemicolonRegex = /;\s*Secure/i const ensureSameSiteNone = ({ cookie, browser, isLocalhost, url }: EnsureSameSiteNoneProps) => { diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index a3faa680f77c..912751d6132f 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -808,9 +808,10 @@ describe('http/response-middleware', function () { ['SameSite=Invalid', output], ['SameSite=None', output], ['', output], - // When there's Secure and no SameSite, it ends up as + // When Secure is first or there's no SameSite, it ends up as // "Secure; SameSite=None" instead of "Secure" being second ['Secure', flippedOutput || output], + ['Secure; SameSite=None', flippedOutput || output], ] } @@ -821,12 +822,12 @@ describe('http/response-middleware', function () { describe('not Firefox', function () { makeScenarios('SameSite=None; Secure', 'Secure; SameSite=None').forEach(([input, output]) => { it(`${input} -> ${output}`, async function () { - const { appendStub, ctx } = prepareContextWithCookie(`cookie=value${input ? '; ' : ''}${input}`) + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`) await testMiddleware([CopyCookiesFromIncomingRes], ctx) expect(appendStub).to.be.calledOnce - expect(appendStub).to.be.calledWith('Set-Cookie', `cookie=value; ${output}`) + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) }) }) }) @@ -834,7 +835,7 @@ describe('http/response-middleware', function () { describe('Firefox + non-localhost', function () { makeScenarios('SameSite=None; Secure', 'Secure; SameSite=None').forEach(([input, output]) => { it(`${input} -> ${output}`, async function () { - const { appendStub, ctx } = prepareContextWithCookie(`cookie=value${input ? '; ' : ''}${input}`, { + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`, { req: { proxiedUrl: 'https://foobar.com' }, ...withFirefox, }) @@ -842,7 +843,7 @@ describe('http/response-middleware', function () { await testMiddleware([CopyCookiesFromIncomingRes], ctx) expect(appendStub).to.be.calledOnce - expect(appendStub).to.be.calledWith('Set-Cookie', `cookie=value; ${output}`) + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) }) }) }) @@ -850,7 +851,7 @@ describe('http/response-middleware', function () { describe('Firefox + https://localhost', function () { makeScenarios('SameSite=None; Secure', 'Secure; SameSite=None').forEach(([input, output]) => { it(`${input} -> ${output}`, async function () { - const { appendStub, ctx } = prepareContextWithCookie(`cookie=value${input ? '; ' : ''}${input}`, { + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`, { req: { proxiedUrl: 'https://localhost:3500' }, ...withFirefox, }) @@ -858,7 +859,7 @@ describe('http/response-middleware', function () { await testMiddleware([CopyCookiesFromIncomingRes], ctx) expect(appendStub).to.be.calledOnce - expect(appendStub).to.be.calledWith('Set-Cookie', `cookie=value; ${output}`) + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) }) }) }) @@ -866,12 +867,12 @@ describe('http/response-middleware', function () { describe('Firefox + http://localhost', function () { makeScenarios('SameSite=None').forEach(([input, output]) => { it(`${input} -> ${output}`, async function () { - const { appendStub, ctx } = prepareContextWithCookie(`cookie=value${input ? '; ' : ''}${input}`, withFirefox) + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`, withFirefox) await testMiddleware([CopyCookiesFromIncomingRes], ctx) expect(appendStub).to.be.calledOnce - expect(appendStub).to.be.calledWith('Set-Cookie', `cookie=value; ${output}`) + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) }) }) }) diff --git a/packages/runner-shared/src/event-manager.js b/packages/runner-shared/src/event-manager.js index 5a0e97d3aebd..07617f443138 100644 --- a/packages/runner-shared/src/event-manager.js +++ b/packages/runner-shared/src/event-manager.js @@ -127,10 +127,7 @@ export const eventManager = { _.each(socketToDriverEvents, (event) => { ws.on(event, (...args) => { - // these events are set up before Cypress is instantiated, so it's - // possible it's undefined when an event fires, but it's okay to - // ignore at that point - Cypress?.emit(event, ...args) + Cypress.emit(event, ...args) }) }) @@ -325,7 +322,7 @@ export const eventManager = { // The window.top should not change between test reloads, and we only need to bind the message event once // Forward all message events to the current instance of the multi-origin communicator window.top?.addEventListener('message', ({ data, source }) => { - Cypress?.primaryOriginCommunicator.onMessage({ data, source }) + Cypress.primaryOriginCommunicator.onMessage({ data, source }) }, false) }, diff --git a/packages/runner/webpack.config.ts b/packages/runner/webpack.config.ts index 5d46f3fa68b7..c31d396efce8 100644 --- a/packages/runner/webpack.config.ts +++ b/packages/runner/webpack.config.ts @@ -85,7 +85,7 @@ mainConfig.resolve = { // @ts-ignore const crossOriginConfig: webpack.Configuration = { - mode: 'development', + mode: 'production', ...getSimpleConfig(), entry: { cypress_cross_origin_runner: [path.resolve(__dirname, 'src/multi-domain.js')], diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 26b8a0ecc533..6ec737bfe2a5 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -13,6 +13,8 @@ /// /// +/// + // types for the `server` package export namespace CyServer { // TODO: pull this from main types diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index bfb7cebb8a94..16645e833d8b 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -205,7 +205,7 @@ export class CdpAutomation { }) } - private onNetworkRequestWillBeSent = async (params: Protocol.Network.RequestWillBeSentEvent) => { + private onNetworkRequestWillBeSent = (params: Protocol.Network.RequestWillBeSentEvent) => { debugVerbose('received networkRequestWillBeSent %o', params) let url = params.request.url diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 1135f65cd908..6e8b28eea96a 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -258,7 +258,16 @@ const _connectToChromeRemoteInterface = function (port, onError, browserDisplayN .then((wsUrl) => { debug('received wsUrl %s for port %d', wsUrl, port) - return CriClient.create(wsUrl, onError) + return CriClient.create({ + target: wsUrl, + onError, + onReconnect (client) { + // if the client disconnects (e.g. due to a computer sleeping), update + // the frame tree on reconnect in cases there were changes while + // the client was disconnected + _updateFrameTree(client)() + }, + }) }) } @@ -336,13 +345,16 @@ const _updateFrameTree = (client) => async () => { debug('update frame tree') gettingFrameTree = new Promise(async (resolve) => { - frameTree = (await client.send('Page.getFrameTree')).frameTree - - debug('frame tree updated') - - gettingFrameTree = null - - resolve() + try { + frameTree = (await client.send('Page.getFrameTree')).frameTree + debug('frame tree updated') + } catch (err) { + debug('failed to update frame tree:', err.stack) + } finally { + gettingFrameTree = null + + resolve() + } }) } @@ -648,8 +660,11 @@ export = { await this._maybeRecordVideo(criClient, options, browser.majorVersion) await this._navigateUsingCRI(criClient, url) await this._handleDownloads(criClient, options.downloadsFolder, automation) - await this._handlePausedRequests(criClient) - _listenForFrameTreeChanges(criClient) + + if (options.experimentalSessionAndOrigin) { + await this._handlePausedRequests(criClient) + _listenForFrameTreeChanges(criClient) + } // return the launched browser process // with additional method to close the remote connection diff --git a/packages/server/lib/browsers/cri-client.ts b/packages/server/lib/browsers/cri-client.ts index 5ebbad6bcbf5..a9901d2b6c45 100644 --- a/packages/server/lib/browsers/cri-client.ts +++ b/packages/server/lib/browsers/cri-client.ts @@ -139,7 +139,14 @@ export { chromeRemoteInterface } type DeferredPromise = { resolve: Function, reject: Function } -export const create = async (target: websocketUrl, onAsynchronousError: Function): Promise => { +interface CriClientOptions { + target: websocketUrl + onError: Function + onReconnect?: (client: CRIWrapper) => void +} + +export const create = async (options: CriClientOptions): Promise => { + const { target, onError, onReconnect } = options const subscriptions: {eventName: CRI.EventName, cb: Function}[] = [] const enableCommands: CRI.Command[] = [] let enqueuedCommands: {command: CRI.Command, params: any, p: DeferredPromise }[] = [] @@ -180,8 +187,12 @@ export const create = async (target: websocketUrl, onAsynchronousError: Function }) enqueuedCommands = [] + + if (onReconnect) { + onReconnect(client) + } } catch (err) { - onAsynchronousError(errors.get('CDP_COULD_NOT_RECONNECT', err)) + onError(errors.get('CDP_COULD_NOT_RECONNECT', err)) } } @@ -285,9 +296,6 @@ export const create = async (target: websocketUrl, onAsynchronousError: Function return cri.close() }, - - // @ts-ignore - reconnect, } return client diff --git a/packages/server/lib/browsers/electron.js b/packages/server/lib/browsers/electron.js index 1c2e10090c1f..aaf60e9eba66 100644 --- a/packages/server/lib/browsers/electron.js +++ b/packages/server/lib/browsers/electron.js @@ -265,7 +265,9 @@ module.exports = { return this._enableDebugger(win.webContents) }) .then(() => { - this._listenToOnBeforeHeaders(win) + if (options.experimentalSessionAndOrigin) { + this._listenToOnBeforeHeaders(win) + } return this._handleDownloads(win, options.downloadsFolder, automation) }) diff --git a/packages/server/lib/browsers/firefox-util.ts b/packages/server/lib/browsers/firefox-util.ts index 832355cdd1b0..43a22fe97f26 100644 --- a/packages/server/lib/browsers/firefox-util.ts +++ b/packages/server/lib/browsers/firefox-util.ts @@ -103,7 +103,10 @@ const attachToTabMemory = Bluebird.method((tab) => { async function setupRemote (remotePort, automation, options) { const wsUrl = await protocol.getWsTargetFor(remotePort, 'Firefox') - const criClient = await CriClient.create(wsUrl, options.onError) + const criClient = await CriClient.create({ + target: wsUrl, + onError: options.onError, + }) const cdpAutomation = new CdpAutomation({ sendDebuggerCommandFn: criClient.send, onFn: criClient.on, diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 9c102afd2cbc..4c74bd6d395d 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -510,7 +510,8 @@ export async function open (browser: Browser, url, options: any = {}, automation }) try { - await firefoxUtil.setup({ automation, + await firefoxUtil.setup({ + automation, extensions: launchOptions.extensions, url, foxdriverPort, diff --git a/packages/server/lib/controllers/files.js b/packages/server/lib/controllers/files.js index 76b34d7bec9c..1fdd9166c5a1 100644 --- a/packages/server/lib/controllers/files.js +++ b/packages/server/lib/controllers/files.js @@ -39,7 +39,7 @@ module.exports = { const iframeOptions = { title: this.getTitle(test), - domain: remoteStates.current().domainName, + domain: remoteStates.getPrimary().domainName, scripts: JSON.stringify(allFilesToSend), } diff --git a/packages/server/lib/controllers/runner.ts b/packages/server/lib/controllers/runner.ts index d12ce38bba78..a3bfec6069ec 100644 --- a/packages/server/lib/controllers/runner.ts +++ b/packages/server/lib/controllers/runner.ts @@ -69,7 +69,7 @@ export const runner = { // } // TODO: Find out what the problem. if (options.testingType === 'e2e') { - config.remote = remoteStates.current() + config.remote = remoteStates.getPrimary() } config.version = pkg.version diff --git a/packages/server/lib/remote_states.ts b/packages/server/lib/remote_states.ts index f9ceb3e6a056..836f237e5e2d 100644 --- a/packages/server/lib/remote_states.ts +++ b/packages/server/lib/remote_states.ts @@ -60,6 +60,14 @@ export class RemoteStates { return _.cloneDeep(state) } + getPrimary () { + const state = Array.from(this.remoteStates.entries())[0][1] + + debug('getting primary remote state: %o', state) + + return state + } + isInOriginStack (url: string): boolean { return this.originStack.includes(cors.getOriginPolicy(url)) } @@ -78,8 +86,8 @@ export class RemoteStates { const stateArray = Array.from(this.remoteStates.entries()) + // reset the remoteStates and originStack to the primary this.remoteStates = new Map([stateArray[0]]) - this.originStack = [stateArray[0][0]] } diff --git a/packages/server/lib/routes.ts b/packages/server/lib/routes.ts index 860054bd09b7..d83221961dc9 100644 --- a/packages/server/lib/routes.ts +++ b/packages/server/lib/routes.ts @@ -49,9 +49,6 @@ export const createCommonRoutes = ({ router.get('/__cypress/iframes/*', (req, res) => { if (testingType === 'e2e') { - // ensure the remote state gets cleaned up from any previous tests/runs - remoteStates.reset() - iframesController.e2e({ config, getSpec, remoteStates }, req, res) } @@ -69,9 +66,6 @@ export const createCommonRoutes = ({ router.get(clientRoute, (req, res) => { debug('Serving Cypress front-end by requested URL:', req.url) - // ensure the remote state gets cleaned up from any previous tests/runs - remoteStates.reset() - runner.serve(req, res, testingType === 'e2e' ? 'runner' : 'runner-ct', { config, testingType, diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index edad746878e2..1d33a4ea5851 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -118,7 +118,7 @@ export abstract class ServerBase { protected _netStubbingState?: NetStubbingState protected _httpsProxy?: httpsProxy protected _eventBus: EventEmitter - protected remoteStates: RemoteStates + protected _remoteStates: RemoteStates constructor () { this.isListening = false @@ -130,7 +130,7 @@ export abstract class ServerBase { this._baseUrl = null this._fileServer = null - this.remoteStates = new RemoteStates(() => { + this._remoteStates = new RemoteStates(() => { return { serverPort: this._port(), fileServerPort: this._fileServer?.port(), @@ -164,6 +164,10 @@ export abstract class ServerBase { return this.ensureProp(this._httpsProxy, 'open') } + get remoteStates () { + return this._remoteStates + } + setupCrossOriginRequestHandling () { this._eventBus.on('cross:origin:delaying:html', (request) => { this.socket.localBus.once('cross:origin:release:html', () => { @@ -212,7 +216,7 @@ export abstract class ServerBase { clientCertificates.loadClientCertificateConfig(config) - this.createNetworkProxy({ config, getCurrentBrowser, remoteStates: this.remoteStates, shouldCorrelatePreRequests }) + this.createNetworkProxy({ config, getCurrentBrowser, remoteStates: this._remoteStates, shouldCorrelatePreRequests }) if (config.experimentalSourceRewriting) { createInitialWorkers() @@ -223,7 +227,7 @@ export abstract class ServerBase { const routeOptions: InitializeRoutes = { config, specsStore, - remoteStates: this.remoteStates, + remoteStates: this._remoteStates, nodeProxy: this.nodeProxy, networkProxy: this._networkProxy!, onError, @@ -234,7 +238,7 @@ export abstract class ServerBase { } this.setupCrossOriginRequestHandling() - this.remoteStates.addEventListeners(this.socket.localBus) + this._remoteStates.addEventListeners(this.socket.localBus) const runnerSpecificRouter = testingType === 'e2e' ? createRoutesE2E(routeOptions) @@ -331,7 +335,7 @@ export abstract class ServerBase { options.onResetServerState = () => { this.networkProxy.reset() this.netStubbingState.reset() - this.remoteStates.reset() + this._remoteStates.reset() } const io = this.socket.startListening(this.server, automation, config, options) @@ -466,7 +470,7 @@ export abstract class ServerBase { const baseUrl = this._baseUrl ?? '' - return this.remoteStates.set(baseUrl) + return this._remoteStates.set(baseUrl) } _close () { diff --git a/packages/server/lib/server-ct.ts b/packages/server/lib/server-ct.ts index 92295bcf5938..63e2fa07baf7 100644 --- a/packages/server/lib/server-ct.ts +++ b/packages/server/lib/server-ct.ts @@ -38,7 +38,7 @@ export class ServerCt extends ServerBase { // once we open set the domain to root by default // which prevents a situation where navigating // to http sites redirects to /__/ cypress - this.remoteStates.set(baseUrl) + this._remoteStates.set(baseUrl) return resolve([port]) }) diff --git a/packages/server/lib/server-e2e.ts b/packages/server/lib/server-e2e.ts index 8dadffa18768..3188bb46b1cf 100644 --- a/packages/server/lib/server-e2e.ts +++ b/packages/server/lib/server-e2e.ts @@ -121,7 +121,7 @@ export class ServerE2E extends ServerBase { // once we open set the domain to root by default // which prevents a situation where navigating // to http sites redirects to /__/ cypress - this.remoteStates.set(baseUrl != null ? baseUrl : '') + this._remoteStates.set(baseUrl != null ? baseUrl : '') return resolve([port, warning]) }) @@ -159,8 +159,8 @@ export class ServerE2E extends ServerBase { const request = this.request let handlingLocalFile = false - const previousRemoteState = this.remoteStates.current() - const previousRemoteStateIsPrimary = this.remoteStates.isPrimaryOrigin(previousRemoteState.origin) + const previousRemoteState = this._remoteStates.current() + const previousRemoteStateIsPrimary = this._remoteStates.isPrimaryOrigin(previousRemoteState.origin) // nuke any hashes from our url since // those those are client only and do @@ -209,7 +209,7 @@ export class ServerE2E extends ServerBase { options.headers['x-cypress-authorization'] = this._fileServer.token - const state = this.remoteStates.set(urlStr, options) + const state = this._remoteStates.set(urlStr, options) urlFile = url.resolve(state.fileServer as string, urlStr) urlStr = url.resolve(state.origin as string, urlStr) @@ -299,7 +299,7 @@ export class ServerE2E extends ServerBase { !((options.hasAlreadyVisitedUrl || options.isCrossOrigin) && !cors.urlOriginsMatch(previousRemoteState.origin, newUrl))) { // if we're not handling a local file set the remote state if (!handlingLocalFile) { - this.remoteStates.set(newUrl as string, options) + this._remoteStates.set(newUrl as string, options) } const responseBufferStream = new stream.PassThrough({ @@ -322,7 +322,7 @@ export class ServerE2E extends ServerBase { restorePreviousRemoteState(previousRemoteState, previousRemoteStateIsPrimary) } - details.isPrimaryOrigin = this.remoteStates.isPrimaryOrigin(newUrl!) + details.isPrimaryOrigin = this._remoteStates.isPrimaryOrigin(newUrl!) return resolve(details) }) @@ -334,7 +334,7 @@ export class ServerE2E extends ServerBase { } const restorePreviousRemoteState = (previousRemoteState: Cypress.RemoteState, previousRemoteStateIsPrimary: boolean) => { - this.remoteStates.set(previousRemoteState, { isCrossOrigin: !previousRemoteStateIsPrimary }) + this._remoteStates.set(previousRemoteState, { isCrossOrigin: !previousRemoteStateIsPrimary }) } // if they're POSTing an object, querystringify their POST body diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 96a14a6b3a98..6c5bf42f48eb 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -71,6 +71,7 @@ const retry = (fn: (res: any) => void) => { } export class SocketBase { + protected experimentalSessionAndOrigin: boolean protected ended: boolean protected _io?: socketIo.SocketIOServer protected testsDir: string | null @@ -78,6 +79,7 @@ export class SocketBase { localBus: EventEmitter constructor (config: Record) { + this.experimentalSessionAndOrigin = config.experimentalSessionAndOrigin this.ended = false this.testsDir = null this.localBus = new EventEmitter() @@ -204,6 +206,11 @@ export class SocketBase { debug('automation:client connected') + // only send the necessary config + automationClient.emit('automation:config', { + experimentalSessionAndOrigin: this.experimentalSessionAndOrigin, + }) + // if our automation disconnects then we're // in trouble and should probably bomb everything automationClient.on('disconnect', () => { diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 2af90355b334..d78eac863bf2 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -70,14 +70,14 @@ describe('lib/browsers/chrome', () => { return chrome.open('chrome', 'http://', {}, this.automation) .then(() => { expect(utils.getPort).to.have.been.calledOnce // to get remote interface port - expect(this.criClient.send.callCount).to.equal(6) expect(this.criClient.send).to.have.been.calledWith('Page.bringToFront') expect(this.criClient.send).to.have.been.calledWith('Page.navigate') expect(this.criClient.send).to.have.been.calledWith('Page.enable') expect(this.criClient.send).to.have.been.calledWith('Page.setDownloadBehavior') expect(this.criClient.send).to.have.been.calledWith('Network.enable') - expect(this.criClient.send).to.have.been.calledWith('Fetch.enable') + + expect(this.criClient.send.callCount).to.equal(5) }) }) @@ -365,6 +365,10 @@ describe('lib/browsers/chrome', () => { }) describe('adding header to AUT iframe request', function () { + const withExperimentalFlagOn = { + experimentalSessionAndOrigin: true, + } + beforeEach(function () { const frameTree = { frameTree: { @@ -388,8 +392,20 @@ describe('lib/browsers/chrome', () => { this.criClient.send.withArgs('Page.getFrameTree').resolves(frameTree) }) + it('does not listen to Fetch.requestPaused if experimental flag is off', async function () { + await chrome.open('chrome', 'http://', { experimentalSessionAndOrigin: false }, this.automation) + + expect(this.criClient.on).not.to.be.calledWith('Fetch.requestPaused') + }) + + it('sends Fetch.enable', async function () { + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) + + expect(this.criClient.send).to.have.been.calledWith('Fetch.enable') + }) + it('does not add header when not a document', async function () { - await chrome.open('chrome', 'http://', {}, this.automation) + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) this.criClient.on.withArgs('Fetch.requestPaused').yield({ requestId: '1234', @@ -402,7 +418,7 @@ describe('lib/browsers/chrome', () => { }) it('does not add header when it is a spec frame request', async function () { - await chrome.open('chrome', 'http://', {}, this.automation) + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) this.criClient.on.withArgs('Page.frameAttached').yield() @@ -421,7 +437,7 @@ describe('lib/browsers/chrome', () => { }) it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', async function () { - await chrome.open('chrome', 'http://', {}, this.automation) + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) this.criClient.on.withArgs('Page.frameAttached').yield() @@ -453,7 +469,7 @@ describe('lib/browsers/chrome', () => { }) it('gets frame tree on Page.frameAttached', async function () { - await chrome.open('chrome', 'http://', {}, this.automation) + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) this.criClient.on.withArgs('Page.frameAttached').yield() @@ -461,7 +477,7 @@ describe('lib/browsers/chrome', () => { }) it('gets frame tree on Page.frameDetached', async function () { - await chrome.open('chrome', 'http://', {}, this.automation) + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) this.criClient.on.withArgs('Page.frameDetached').yield() diff --git a/packages/server/test/unit/browsers/cri-client_spec.ts b/packages/server/test/unit/browsers/cri-client_spec.ts index 597fa1463a4a..e8dae3f359ed 100644 --- a/packages/server/test/unit/browsers/cri-client_spec.ts +++ b/packages/server/test/unit/browsers/cri-client_spec.ts @@ -40,7 +40,12 @@ describe('lib/browsers/cri-client', function () { 'chrome-remote-interface': criImport, }) - getClient = () => criClient.create(DEBUGGER_URL, onError) + getClient = () => { + return criClient.create({ + target: DEBUGGER_URL, + onError, + }) + } }) context('.create', function () { diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index 42f4e2706670..1b2ec3bd5106 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -286,7 +286,21 @@ describe('lib/browsers/electron', () => { }) }) + it('does not listen to `onBeforeSendHeaders` if experimental flag is off', function () { + this.options.experimentalSessionAndOrigin = false + sinon.stub(this.win.webContents.session.webRequest, 'onBeforeSendHeaders') + + return electron._launch(this.win, this.url, this.automation, this.options) + .then(() => { + expect(this.win.webContents.session.webRequest.onBeforeSendHeaders).not.to.be.called + }) + }) + describe('adding header aut iframe requests', function () { + beforeEach(function () { + this.options.experimentalSessionAndOrigin = true + }) + it('does not add header if not a sub frame', function () { sinon.stub(this.win.webContents.session.webRequest, 'onBeforeSendHeaders') diff --git a/packages/server/test/unit/remote_states.spec.ts b/packages/server/test/unit/remote_states.spec.ts index 6ad429044c4d..ae3ce77a0e89 100644 --- a/packages/server/test/unit/remote_states.spec.ts +++ b/packages/server/test/unit/remote_states.spec.ts @@ -78,6 +78,44 @@ describe('remote states', () => { }) }) + context('#getPrimary', () => { + it('returns the primary when there is only the primary in remote states', function () { + const state = this.remoteStates.getPrimary() + + expect(state).to.deep.equal({ + auth: undefined, + origin: 'http://localhost:3500', + strategy: 'http', + domainName: 'localhost', + fileServer: null, + props: { + port: '3500', + domain: '', + tld: 'localhost', + }, + }) + }) + + it('returns the primary when there are multiple remote states', function () { + this.remoteStates.set('https://staging.google.com/foo/bar', { isCrossOrigin: true }) + + const state = this.remoteStates.getPrimary() + + expect(state).to.deep.equal({ + auth: undefined, + origin: 'http://localhost:3500', + strategy: 'http', + domainName: 'localhost', + fileServer: null, + props: { + port: '3500', + domain: '', + tld: 'localhost', + }, + }) + }) + }) + context('#isInOriginStack', () => { it('returns true when the requested url is in the origin stack', function () { const isInOriginStack = this.remoteStates.isInOriginStack('http://localhost:3500') diff --git a/system-tests/test/multi_domain_retries_spec.ts b/system-tests/test/multi_domain_retries_spec.ts index a45d623d9766..111e7b34d826 100644 --- a/system-tests/test/multi_domain_retries_spec.ts +++ b/system-tests/test/multi_domain_retries_spec.ts @@ -38,8 +38,9 @@ describe('e2e cy.origin retries', () => { const res = await exec() // verify that retrying tests with cy.origin doesn't cause serialization problems to spec bridges on test:before:run:async - expect(res.stdout).to.not.contain('TypeError') - expect(res.stdout).to.not.contain('Cannot set property message of which has only a getter') + expect(res.stdout).not.to.contain('TypeError') + expect(res.stdout).not.to.contain('Cannot set property message') + expect(res.stdout).not.to.contain('which has only a getter') expect(res.stdout).to.contain('AssertionError') expect(res.stdout).to.contain('expected true to be false')