diff --git a/packages/driver/cypress/integration/commands/waiting_spec.js b/packages/driver/cypress/integration/commands/waiting_spec.js index 3134a9f38691..00ec884a2c46 100644 --- a/packages/driver/cypress/integration/commands/waiting_spec.js +++ b/packages/driver/cypress/integration/commands/waiting_spec.js @@ -68,7 +68,7 @@ describe('src/cy/commands/waiting', () => { return null }) - .wait('@fetch').then((xhr) => { + .wait('@fetch.response').then((xhr) => { expect(xhr.responseBody).to.deep.eq(response) }) }) @@ -302,7 +302,7 @@ describe('src/cy/commands/waiting', () => { .wait('getAny').then(() => {}) }) - it('throws when 2nd alias doesnt match any registered alias', (done) => { + it('throws when 2nd alias doesn\'t match any registered alias', (done) => { cy.on('fail', (err) => { expect(err.message).to.eq('`cy.wait()` could not find a registered alias for: `@bar`.\nAvailable aliases are: `foo`.') @@ -339,7 +339,7 @@ describe('src/cy/commands/waiting', () => { .wait(['@foo', 'bar']) }) - it('throws when 2nd alias isnt a route alias', (done) => { + it('throws when 2nd alias isn\'t a route alias', (done) => { cy.on('fail', (err) => { expect(err.message).to.include('`cy.wait()` only accepts aliases for routes.\nThe alias: `bar` did not match a route.') expect(err.docsUrl).to.eq('https://on.cypress.io/wait') @@ -448,7 +448,7 @@ describe('src/cy/commands/waiting', () => { .wait(['@foo', 'bar']) }) - it('does not throw again when 2nd alias doesnt reference a route', { + it('does not throw again when 2nd alias doesn\'t reference a route', { requestTimeout: 100, }, (done) => { Promise.onPossiblyUnhandledRejection(done) @@ -1132,7 +1132,7 @@ describe('src/cy/commands/waiting', () => { expect(this.lastLog.invoke('consoleProps')).to.deep.eq({ Command: 'wait', 'Waited For': 'getFoo, getBar', - Yielded: [xhrs[0], xhrs[1]], // explictly create the array here + Yielded: [xhrs[0], xhrs[1]], // explicitly create the array here }) }) }) diff --git a/packages/driver/cypress/integration/e2e/origin/commands/aliasing.spec.ts b/packages/driver/cypress/integration/e2e/origin/commands/aliasing.spec.ts index cd36fef8fbd2..0b879b654f6b 100644 --- a/packages/driver/cypress/integration/e2e/origin/commands/aliasing.spec.ts +++ b/packages/driver/cypress/integration/e2e/origin/commands/aliasing.spec.ts @@ -3,13 +3,29 @@ import { findCrossOriginLogs } from '../../../../support/utils' context('cy.origin aliasing', () => { beforeEach(() => { cy.visit('/fixtures/primary-origin.html') - cy.get('a[data-cy="dom-link"]').click() }) - it('.as()', () => { - cy.origin('http://foobar.com:3500', () => { - cy.get(':checkbox[name="colors"][value="blue"]').as('checkbox') - cy.get('@checkbox').click().should('be.checked') + context('.as()', () => { + it('supports dom elements inside origin', () => { + cy.get('a[data-cy="dom-link"]').click() + + cy.origin('http://foobar.com:3500', () => { + cy.get(':checkbox[name="colors"][value="blue"]').as('checkbox') + cy.get('@checkbox').click().should('be.checked') + }) + }) + + it('fails for dom elements outside origin', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.equal('`cy.get()` could not find a registered alias for: `@welcome_button`.\nYou have not aliased anything yet.') + done() + }) + + cy.get('[data-cy="welcome"]').as('welcome_button') + + cy.origin('http://foobar.com:3500', () => { + cy.get('@welcome_button').click() + }) }) }) @@ -25,6 +41,8 @@ context('cy.origin aliasing', () => { }) it('.as()', () => { + cy.get('a[data-cy="dom-link"]').click() + cy.origin('http://foobar.com:3500', () => { cy.get('#button').as('buttonAlias') }) diff --git a/packages/driver/cypress/integration/e2e/origin/commands/waiting.spec.ts b/packages/driver/cypress/integration/e2e/origin/commands/waiting.spec.ts index d6f48af7acf9..f758ff1300e1 100644 --- a/packages/driver/cypress/integration/e2e/origin/commands/waiting.spec.ts +++ b/packages/driver/cypress/integration/e2e/origin/commands/waiting.spec.ts @@ -1,33 +1,337 @@ import { findCrossOriginLogs } from '../../../../support/utils' +declare global { + interface Window { + xhrGet: any + abortRequests: any + } +} + +let reqQueue: XMLHttpRequest[] = [] + +const xhrGet = (url) => { + const xhr = new window.XMLHttpRequest() + + xhr.open('GET', url) + reqQueue.push(xhr) + xhr.send() +} + +const abortRequests = () => { + reqQueue.forEach((xhr) => xhr.abort()) + reqQueue = [] +} + context('cy.origin waiting', () => { + before(() => { + cy.origin('http://foobar.com:3500', () => { + let reqQueue: XMLHttpRequest[] = [] + + window.xhrGet = (url) => { + const xhr = new window.XMLHttpRequest() + + xhr.open('GET', url) + reqQueue.push(xhr) + xhr.send() + } + + window.abortRequests = () => { + reqQueue.forEach((xhr) => xhr.abort()) + reqQueue = [] + } + }) + }) + + let logs: Map + beforeEach(() => { + cy.origin('http://foobar.com:3500', () => { + window.abortRequests() + }) + + abortRequests() + + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + cy.visit('/fixtures/primary-origin.html') - cy.get('a[data-cy="cross-origin-secondary-link"]').click() }) - it('.wait()', () => { - cy.origin('http://foobar.com:3500', () => { - const delay = cy.spy(Cypress.Promise, 'delay') + context('number', () => { + it('waits for the specified value', () => { + cy.origin('http://foobar.com:3500', () => { + const delay = cy.spy(Cypress.Promise, 'delay') - cy.wait(50).then(() => { - expect(delay).to.be.calledWith(50, 'wait') + cy.wait(50).then(() => { + expect(delay).to.be.calledWith(50, 'wait') + }) }) }) }) - context('#consoleProps', () => { - let logs: Map + context('alias', () => { + it('waits for the route alias to have a request', () => { + cy.intercept('/foo', (req) => { + // delay the response so only the request will be available + req.reply({ + delay: 500, + }) + }).as('foo') - beforeEach(() => { - logs = new Map() + cy.once('command:retry', () => xhrGet('/foo')) - cy.on('log:changed', (attrs, log) => { - logs.set(attrs.id, log) + cy.origin('http://www.foobar.com:3500', () => { + cy.wait('@foo.request').then((xhr) => { + expect(xhr.request.url).to.include('/foo') + expect(xhr.response).to.be.undefined + }) }) }) - it('.wait()', () => { + it('waits for the route alias to have a response', () => { + const response = { foo: 'foo' } + + cy.intercept('/foo', (req) => { + // delay the response to ensure the wait will wait for response + req.reply({ + delay: 100, + body: response, + }) + }).as('foo') + + cy.once('command:retry', () => xhrGet('/foo')) + + cy.origin('http://www.foobar.com:3500', { args: { response } }, ({ response }) => { + cy.wait('@foo.response').then((xhr) => { + expect(xhr.request.url).to.include('/foo') + expect(xhr.response?.body).to.deep.equal(response) + }) + }) + }) + + it('waits for the route alias (aliased through \'as\')', () => { + const response = { foo: 'foo' } + + cy.intercept('/foo', response).as('foo') + + cy.origin('http://www.foobar.com:3500', { args: { response } }, ({ response }) => { + cy.then(() => window.xhrGet('/foo')) + cy.wait('@foo').its('response.body').should('deep.equal', response) + }) + }) + + it('waits for the route alias (aliased through req object)', () => { + const response = { foo: 'foo' } + + cy.intercept('/foo', (req) => { + req.reply(response) + req.alias = 'foo' + }) + + cy.origin('http://www.foobar.com:3500', { args: { response } }, ({ response }) => { + cy.then(() => window.xhrGet('/foo')) + + cy.wait('@foo').its('response.body').should('deep.equal', response) + }) + }) + + it('has the correct log properties', () => { + const response = { foo: 'foo' } + + cy.intercept('/foo', response).as('foo') + + cy.origin('http://www.foobar.com:3500', { args: { response } }, ({ response }) => { + cy.then(() => window.xhrGet('/foo')) + cy.wait('@foo').its('response.body').should('deep.equal', response) + }) + + cy.shouldWithTimeout(() => { + const actualLog = Cypress._.pick(findCrossOriginLogs('wait', logs, 'localhost'), + ['name', 'referencesAlias', 'aliasType', 'type', 'instrument', 'message']) + + const expectedLog = { + name: 'wait', + referencesAlias: [{ name: 'foo', cardinal: 1, ordinal: '1st' }], + aliasType: 'route', + type: 'parent', + instrument: 'command', + message: '', + } + + expect(actualLog).to.deep.equal(expectedLog) + }) + }) + + it('doesn\'t log when log: false', () => { + cy.intercept('/foo', {}).as('foo') + + cy.origin('http://www.foobar.com:3500', () => { + cy.then(() => window.xhrGet('/foo')) + + cy.wait('@foo', { log: false }) + }) + + cy.shouldWithTimeout(() => { + const expectedLogs = findCrossOriginLogs('wait', logs, 'localhost') + + expect(expectedLogs).to.be.empty + }) + }) + + it('waits for multiple aliases', () => { + const fooResponse = { foo: 'foo' } + const barResponse = { bar: 'bar' } + + cy.intercept('/foo', fooResponse).as('foo') + cy.intercept('/bar', barResponse).as('bar') + + cy.origin('http://www.foobar.com:3500', { args: { fooResponse, barResponse } }, ({ fooResponse, barResponse }) => { + cy.then(() => { + window.xhrGet('/foo') + window.xhrGet('/bar') + }) + + cy.wait(['@foo', '@bar']).then((interceptions) => { + expect(interceptions[0].response?.body).to.deep.equal(fooResponse) + expect(interceptions[1].response?.body).to.deep.equal(barResponse) + }) + }) + }) + + it('waits for multiple aliases using separate waits', () => { + const fooResponse = { foo: 'foo' } + const barResponse = { bar: 'bar' } + + cy.intercept('/foo', fooResponse).as('foo') + cy.intercept('/bar', barResponse).as('bar') + + cy.origin('http://www.foobar.com:3500', { args: { fooResponse, barResponse } }, ({ fooResponse, barResponse }) => { + cy.then(() => { + window.xhrGet('/foo') + window.xhrGet('/bar') + }) + + cy.wait('@foo').then((interception) => { + expect(interception.response?.body).to.deep.equal(fooResponse) + }) + .wait('@bar').then((interception) => { + expect(interception.response?.body).to.deep.equal(barResponse) + }) + }) + }) + + context('errors', () => { + it('throws when request is not made', { requestTimeout: 100 }, (done) => { + cy.on('fail', (err) => { + expect(err.message).to.equal('Timed out retrying after 100ms: `cy.wait()` timed out waiting `100ms` for the 1st request to the route: `not_called`. No request ever occurred.') + expect(err.docsUrl).to.equal('https://on.cypress.io/wait') + done() + }) + + cy.intercept('/not_called').as('not_called') + + cy.origin('http://www.foobar.com:3500', () => { + cy.wait('@not_called') + }) + }) + + it('throw when alias doesn\'t match any registered alias', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.equal('`cy.wait()` could not find a registered alias for: `@not_found`.\nAvailable aliases are: `foo`.') + done() + }) + + cy.intercept('/foo', {}).as('foo') + + cy.origin('http://www.foobar.com:3500', () => { + cy.wait('@not_found') + }) + }) + + it('throws when 2nd alias doesn\'t match any registered alias', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('`cy.wait()` could not find a registered alias for: `@bar`.\nAvailable aliases are: `foo`.') + + done() + }) + + cy.intercept('/foo', {}).as('foo') + + cy.origin('http://www.foobar.com:3500', () => { + cy.then(() => window.xhrGet('/foo')) + .wait(['@foo', '@bar']) + }) + }) + + it('throws when alias is missing \'@\' but matches an available alias', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('Invalid alias: `foo`.\nYou forgot the `@`. It should be written as: `@foo`.') + + done() + }) + + cy.intercept('/foo', {}).as('foo') + + cy.origin('http://www.foobar.com:3500', () => { + cy.wait('foo') + }) + }) + + it('throws when 2nd alias isn\'t a route alias', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.wait()` only accepts aliases for routes.\nThe alias: `bar` did not match a route.') + expect(err.docsUrl).to.eq('https://on.cypress.io/wait') + + done() + }) + + cy.intercept('/foo', {}).as('foo') + cy.get('body').as('bar') + + cy.origin('http://www.foobar.com:3500', () => { + cy.then(() => window.xhrGet('/foo')) + .wait(['@foo', '@bar']) + }) + }) + + it('throws when foo cannot resolve', { requestTimeout: 100 }, (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.wait()` timed out waiting `100ms` for the 1st request to the route: `foo`. No request ever occurred.') + + done() + }) + + cy.once('command:retry', () => xhrGet('/bar')) + + cy.intercept('/foo', {}).as('foo') + cy.intercept('/bar', {}).as('bar') + + cy.origin('http://www.foobar.com:3500', () => { + cy.wait(['@foo', '@bar']) + }) + }) + + it('throws when passed multiple string arguments', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('`cy.wait()` was passed invalid arguments. You cannot pass multiple strings. If you\'re trying to wait for multiple routes, use an array.') + expect(err.docsUrl).to.eq('https://on.cypress.io/wait') + + done() + }) + + cy.origin('http://www.foobar.com:3500', () => { + // @ts-ignore + cy.wait('@foo', '@bar') + }) + }) + }) + }) + + context('#consoleProps', () => { + it('number', () => { cy.origin('http://foobar.com:3500', () => { cy.wait(200) }) @@ -36,7 +340,54 @@ context('cy.origin waiting', () => { const { consoleProps } = findCrossOriginLogs('wait', logs, 'foobar.com') expect(consoleProps.Command).to.equal('wait') - expect(consoleProps).to.have.property('Waited For').to.equal('200ms before continuing') + expect(consoleProps['Waited For']).to.equal('200ms before continuing') + }) + }) + + context('alias', () => { + it('waiting on one alias', () => { + cy.intercept('/foo', {}).as('foo') + + cy.origin('http://www.foobar.com:3500', () => { + cy.then(() => window.xhrGet('/foo')) + cy.wait('@foo') + }) + + cy.shouldWithTimeout(() => { + const log = findCrossOriginLogs('wait', logs, 'localhost') + const consoleProps = log.consoleProps() + + expect(consoleProps.Command).to.equal('wait') + expect(consoleProps['Waited For']).to.equal('foo') + expect(consoleProps.Yielded).to.equal(Cypress.state('routes')[consoleProps.Yielded.routeId].requests[consoleProps.Yielded.id]) + }) + }) + + it('waiting on multiple aliases', () => { + cy.intercept('/foo', {}).as('foo') + cy.intercept('/bar', {}).as('bar') + + cy.origin('http://www.foobar.com:3500', () => { + cy.then(() => { + window.xhrGet('/foo') + window.xhrGet('/bar') + }) + + cy.wait(['@foo', '@bar']) + }) + + cy.shouldWithTimeout(() => { + const log = findCrossOriginLogs('wait', logs, 'localhost') + const consoleProps = log.consoleProps() + + expect(consoleProps.Command).to.equal('wait') + expect(consoleProps['Waited For']).to.equal('foo, bar') + const routes = Cypress.state('routes') + const yielded = consoleProps.Yielded + const expectedRoutes = [routes[yielded[0].routeId].requests[yielded[0].id], routes[yielded[1].routeId].requests[yielded[1].id]] + + expect(yielded).to.deep.equal(expectedRoutes) + }) }) }) }) diff --git a/packages/driver/src/cy/aliases.ts b/packages/driver/src/cy/aliases.ts index 3aa87e485ed9..ddd27ce60655 100644 --- a/packages/driver/src/cy/aliases.ts +++ b/packages/driver/src/cy/aliases.ts @@ -31,14 +31,14 @@ export const create = (cy: $Cy) => ({ getAlias (name, cmd, log) { const aliases = cy.state('aliases') || {} - // bail if the name doesnt reference an alias + // bail if the name doesn't reference an alias if (!aliasRe.test(name)) { return } + // slice off the '@' const alias = aliases[name.slice(1)] - // slice off the '@' if (!alias) { this.aliasNotFoundFor(name, cmd, log) } diff --git a/packages/driver/src/cy/commands/waiting.ts b/packages/driver/src/cy/commands/waiting.ts index cfcbfafe5649..b0d19ff0fd56 100644 --- a/packages/driver/src/cy/commands/waiting.ts +++ b/packages/driver/src/cy/commands/waiting.ts @@ -56,11 +56,23 @@ export default (Commands, Cypress, cy, state) => { let log if (options.log !== false) { + let specBridgeLogOptions = {} + + // if this came from the spec bridge, we need to set a few additional properties to ensure the log displays correctly + // otherwise, these props will be pulled from the current command which will be cy.origin on the primary + if (options.isCrossOriginSpecBridge) { + specBridgeLogOptions = { + name: 'wait', + message: '', + } + } + log = options._log = Cypress.log({ type: 'parent', aliasType: 'route', // avoid circular reference options: _.omit(options, '_log'), + ...specBridgeLogOptions, }) } @@ -270,8 +282,50 @@ export default (Commands, Cypress, cy, state) => { }) } + Cypress.primaryOriginCommunicator.on('wait:for:xhr', ({ args: [str, options] }, originPolicy) => { + options.isCrossOriginSpecBridge = true + waitString(null, str, options).then((responses) => { + Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'wait:for:xhr:end', responses) + }).catch((err) => { + options._log?.error(err) + err.hasSpecBridgeError = true + Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'wait:for:xhr:end', err) + }) + }) + + const delegateToPrimaryOrigin = ([_subject, str, options]) => { + return new Promise((resolve, reject) => { + Cypress.specBridgeCommunicator.once('wait:for:xhr:end', (responsesOrErr) => { + // determine if this is an error by checking if there is a spec bridge error + if (responsesOrErr.hasSpecBridgeError) { + delete responsesOrErr.hasSpecBridgeError + if (options.log) { + Cypress.state('onBeforeLog', (log) => { + // skip this 'wait' log since it was already added through the primary + if (log.get('name') === 'wait') { + // unbind this function so we don't impact any other logs + cy.state('onBeforeLog', null) + + return false + } + + return + }) + } + + reject(responsesOrErr) + } + + resolve(responsesOrErr) + }) + + // subject is not needed when waiting on aliased requests since the request/response will be yielded + Cypress.specBridgeCommunicator.toPrimary('wait:for:xhr', { args: [str, options] }) + }) + } + Commands.addAll({ prevSubject: 'optional' }, { - wait (subject, msOrAlias, options = {}) { + wait (subject, msOrAlias, options: { log?: boolean } = {}) { // check to ensure options is an object // if its a string the user most likely is trying // to wait on multiple aliases and forget to make this @@ -292,11 +346,11 @@ export default (Commands, Cypress, cy, state) => { return waitNumber.apply(window, args) } - if (_.isString(msOrAlias)) { - return waitString.apply(window, args) - } + if (_.isString(msOrAlias) || (_.isArray(msOrAlias) && !_.isEmpty(msOrAlias))) { + if (Cypress.isCrossOriginSpecBridge) { + return delegateToPrimaryOrigin(args) + } - if (_.isArray(msOrAlias) && !_.isEmpty(msOrAlias)) { return waitString.apply(window, args) } diff --git a/packages/driver/src/cy/retries.ts b/packages/driver/src/cy/retries.ts index c8fb7b800662..94ba98515f82 100644 --- a/packages/driver/src/cy/retries.ts +++ b/packages/driver/src/cy/retries.ts @@ -80,7 +80,7 @@ export const create = (Cypress: ICypress, state: StateFunc, timeout: $Cy['timeou const autOrigin = Cypress.state('autOrigin') const commandOrigin = window.location.origin - if (autOrigin && !cors.urlOriginsMatch(commandOrigin, autOrigin)) { + if (!options.isCrossOriginSpecBridge && autOrigin && !cors.urlOriginsMatch(commandOrigin, autOrigin)) { const appendMsg = errByPath('miscellaneous.cross_origin_command', { commandOrigin, autOrigin, diff --git a/packages/runner/webpack.config.ts b/packages/runner/webpack.config.ts index 64173be899e1..359eae8664f1 100644 --- a/packages/runner/webpack.config.ts +++ b/packages/runner/webpack.config.ts @@ -58,7 +58,6 @@ const mainConfig: webpack.Configuration = { }, } -// @ts-ignore mainConfig.plugins = [ // @ts-ignore ...mainConfig.plugins, @@ -85,8 +84,7 @@ mainConfig.resolve = { // @ts-ignore const crossOriginConfig: webpack.Configuration = { - mode: 'production', - ...getSimpleConfig(), + ...commonConfig, entry: { cypress_cross_origin_runner: [path.resolve(__dirname, 'src/cross-origin.js')], },