diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index 94c6d8a28870..948de64c5a39 100644 --- a/.circleci/cache-version.txt +++ b/.circleci/cache-version.txt @@ -1,3 +1,3 @@ # Bump this version to force CI to re-create the cache from scratch. -5-21-2025 +6-9-2025 diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 93a0126489ef..1368edd6aaad 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -19,6 +19,7 @@ _Released 07/01/2025 (PENDING)_ **Features:** - [`tsx`](https://tsx.is/) is now used in all cases to run the Cypress config, replacing [ts-node](https://github.com/TypeStrong/ts-node) for TypeScript and Node for commonjs/ESM. This should allow for more interoperability for users who are using any variant of ES Modules. Addresses [#8090](https://github.com/cypress-io/cypress/issues/8090), [#15724](https://github.com/cypress-io/cypress/issues/15724), [#21805](https://github.com/cypress-io/cypress/issues/21805), [#22273](https://github.com/cypress-io/cypress/issues/22273), [#22747](https://github.com/cypress-io/cypress/issues/22747), [#23141](https://github.com/cypress-io/cypress/issues/23141), [#25958](https://github.com/cypress-io/cypress/issues/25958), [#25959](https://github.com/cypress-io/cypress/issues/25959), [#26606](https://github.com/cypress-io/cypress/issues/26606), [#27359](https://github.com/cypress-io/cypress/issues/27359), [#27450](https://github.com/cypress-io/cypress/issues/27450), [#28442](https://github.com/cypress-io/cypress/issues/28442), [#30318](https://github.com/cypress-io/cypress/issues/30318), [#30718](https://github.com/cypress-io/cypress/issues/30718), [#30907](https://github.com/cypress-io/cypress/issues/30907), [#30915](https://github.com/cypress-io/cypress/issues/30915), [#30925](https://github.com/cypress-io/cypress/issues/30925), [#30954](https://github.com/cypress-io/cypress/issues/30954) and [#31185](https://github.com/cypress-io/cypress/issues/31185). +- [`cy.url()`](https://docs.cypress.io/api/commands/url), [`cy.hash()`](https://docs.cypress.io/api/commands/hash), [`cy.go()`](https://docs.cypress.io/api/commands/go), [`cy.reload()`](https://docs.cypress.io/api/commands/reload), [`cy.title()`](https://docs.cypress.io/api/commands/title), and [`cy.location()`](https://docs.cypress.io/api/commands/location) now use the automation client (CDP for Chromium browsers and WebDriver BiDi for Firefox) to return the appropriate values from the commands to the user instead of the window object. This is to avoid cross origin issues with [`cy.origin()`](https://docs.cypress.io/api/commands/origin) so these commands can be invoked anywhere inside a Cypress test without having to worry about origin access issues. Experimental Webkit still will use the window object to retrieve these values. Also, [`cy.window()`](https://docs.cypress.io/api/commands/window) will always return the current window object, regardless of origin restrictions. Not every property from the window object will be accessible depending on the origin context. Addresses [#31196](https://github.com/cypress-io/cypress/issues/31196). **Misc:** diff --git a/packages/driver/cypress/e2e/commands/location.cy.js b/packages/driver/cypress/e2e/commands/location.cy.js index 1f6ec80699a3..1bbd5f93b83b 100644 --- a/packages/driver/cypress/e2e/commands/location.cy.js +++ b/packages/driver/cypress/e2e/commands/location.cy.js @@ -23,12 +23,15 @@ describe('src/cy/commands/location', () => { cy.url().should('match', /baz/).and('eq', 'http://localhost:3500/foo/bar/baz.html') }) - it('catches thrown errors', () => { - cy.stub(Cypress.utils, 'locToString') - .onFirstCall().throws(new Error) - .onSecondCall().returns('http://localhost:3500/baz.html') + it('propagates thrown errors from CDP', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('CDP was unable to find the AUT iframe') + done() + }) + + cy.stub(Cypress, 'automation').withArgs('get:aut:url').rejects(new Error('CDP was unable to find the AUT iframe')) - cy.url().should('include', '/baz.html') + cy.url() }) // https://github.com/cypress-io/cypress/issues/17399 @@ -380,7 +383,16 @@ describe('src/cy/commands/location', () => { context('#location', () => { it('returns the location object', () => { cy.location().then((loc) => { - expect(loc).to.have.keys(['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'pathname', 'port', 'protocol', 'search', 'origin', 'superDomainOrigin', 'superDomain', 'toString']) + expect(loc).to.have.property('hash') + expect(loc).to.have.property('host') + expect(loc).to.have.property('hostname') + expect(loc).to.have.property('href') + expect(loc).to.have.property('origin') + expect(loc).to.have.property('pathname') + expect(loc).to.have.property('port') + expect(loc).to.have.property('protocol') + expect(loc).to.have.property('search') + expect(loc).to.have.property('searchParams') }) }) @@ -402,15 +414,13 @@ describe('src/cy/commands/location', () => { // https://github.com/cypress-io/cypress/issues/16463 it('eventually returns a given key', function () { - cy.stub(cy, 'getRemoteLocation') - .onFirstCall().returns('') - .onSecondCall().returns({ - pathname: '/my/path', - }) + cy.stub(Cypress, 'automation').withArgs('get:aut:url') + .onFirstCall().resolves('http://localhost:3500') + .onSecondCall().resolves('http://localhost:3500/my/path') cy.location('pathname').should('equal', '/my/path') .then(() => { - expect(cy.getRemoteLocation).to.have.been.calledTwice + expect(Cypress.automation).to.have.been.calledTwice }) }) @@ -614,7 +624,8 @@ describe('src/cy/commands/location', () => { expect(_.keys(consoleProps)).to.deep.eq(['name', 'type', 'props']) expect(consoleProps.name).to.eq('location') expect(consoleProps.type).to.eq('command') - expect(_.keys(consoleProps.props.Yielded)).to.deep.eq(['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'origin', 'pathname', 'port', 'protocol', 'search', 'superDomainOrigin', 'superDomain', 'toString']) + + expect(_.keys(consoleProps.props.Yielded)).to.deep.eq(['hash', 'host', 'hostname', 'href', 'origin', 'pathname', 'port', 'protocol', 'search', 'searchParams']) }) }) }) diff --git a/packages/driver/cypress/e2e/commands/navigation.cy.js b/packages/driver/cypress/e2e/commands/navigation.cy.js index 2afd8086266d..3b9e9de6a223 100644 --- a/packages/driver/cypress/e2e/commands/navigation.cy.js +++ b/packages/driver/cypress/e2e/commands/navigation.cy.js @@ -11,35 +11,23 @@ describe('src/cy/commands/navigation', () => { }) it('calls into window.location.reload', () => { - const locReload = cy.spy(Cypress.utils, 'locReload') - - cy.reload().then(() => { - expect(locReload).to.be.calledWith(false) + cy.on('fail', () => { + expect(Cypress.automation).to.be.calledWith('reload:aut:frame', { forceReload: false }) }) - }) - it('can pass forceReload', () => { - const locReload = cy.spy(Cypress.utils, 'locReload') + cy.stub(Cypress, 'automation').withArgs('reload:aut:frame', { forceReload: false }).resolves() - cy.reload(true).then(() => { - expect(locReload).to.be.calledWith(true) - }) + cy.reload({ timeout: 1000 }) }) it('can pass forceReload + options', () => { - const locReload = cy.spy(Cypress.utils, 'locReload') - - cy.reload(true, {}).then(() => { - expect(locReload).to.be.calledWith(true) + cy.on('fail', () => { + expect(Cypress.automation).to.be.calledWith('reload:aut:frame', { forceReload: true }) }) - }) - it('can pass just options', () => { - const locReload = cy.spy(Cypress.utils, 'locReload') + cy.stub(Cypress, 'automation').withArgs('reload:aut:frame', { forceReload: true }).resolves() - cy.reload({}).then(() => { - expect(locReload).to.be.calledWith(false) - }) + cy.reload(true, { timeout: 1000 }) }) it('returns the window object', () => { @@ -596,14 +584,15 @@ describe('src/cy/commands/navigation', () => { const { lastLog } = this beforeunload = true - expect(lastLog.get('snapshots').length).to.eq(1) + expect(lastLog.get('snapshots').length).to.eq(2) expect(lastLog.get('snapshots')[0].name).to.eq('before') expect(lastLog.get('snapshots')[0].body).to.be.an('object') return undefined }) - cy.go('back').then(function () { + // wait for the beforeunload event to be fired after the history navigation + cy.go('back').wait(100).then(function () { const { lastLog } = this expect(beforeunload).to.be.true diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts index 3210ddda03cc..ca90333bdc44 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts @@ -188,101 +188,103 @@ context('cy.origin actions', { browser: '!webkit' }, () => { }) }) - context('cross-origin AUT errors', () => { - // We only need to check .get here because the other commands are chained off of it. - // the exceptions are window(), document(), title(), url(), hash(), location(), go(), reload(), and scrollTo() - const assertOriginFailure = (err: Error, done: () => void) => { - expect(err.message).to.include(`The command was expected to run against origin \`http://localhost:3500\` but the application is at origin \`http://www.foobar.com:3500\`.`) - expect(err.message).to.include(`This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly.`) - expect(err.message).to.include(`Using \`cy.origin()\` to wrap the commands run on \`http://www.foobar.com:3500\` will likely fix this issue.`) - expect(err.message).to.include(`cy.origin('http://www.foobar.com:3500', () => {\`\n\` \`\n\`})`) - - // make sure that the secondary origin failures do NOT show up as spec failures or AUT failures - expect(err.message).not.to.include(`The following error originated from your test code, not from Cypress`) - expect(err.message).not.to.include(`The following error originated from your application code, not from Cypress`) - done() - } - - it('.get()', { defaultCommandTimeout: 50 }, (done) => { - cy.on('fail', (err) => { - expect(err.message).to.include(`Timed out retrying after 50ms:`) - assertOriginFailure(err, done) - }) - - cy.get('a[data-cy="dom-link"]').click() - cy.get('#button') - }) - + // With Cypress 15, window() will work always without cy.origin(). + // However, users may not have access to the AUT window object, so cy.window() yielded window objects + // may return cross-origin errors. + context('cross-origin AUT commands working with cy.origin()', () => { it('.window()', (done) => { - cy.on('fail', (err) => { - assertOriginFailure(err, done) - }) - cy.get('a[data-cy="dom-link"]').click() - cy.window() - }) - - it('.document()', (done) => { - cy.on('fail', (err) => { - assertOriginFailure(err, done) + cy.window().then((win) => { + // The window is in a cross-origin state, but users are able to yield the command + // as well as basic accessible properties + expect(win.length).to.equal(2) + try { + // but cannot access cross-origin properties + win[0].location.href + } catch (e) { + expect(e.name).to.equal('SecurityError') + if (Cypress.isBrowser('firefox')) { + expect(e.message).to.include('Permission denied to get property "href" on cross-origin object') + } else { + expect(e.message).to.include('Blocked a frame with origin "http://localhost:3500" from accessing a cross-origin frame.') + } + + done() + } }) + }) + it('.reload()', () => { cy.get('a[data-cy="dom-link"]').click() - cy.document() + cy.reload() }) - it('.title()', (done) => { - cy.on('fail', (err) => { - assertOriginFailure(err, done) - }) - + it('.url()', () => { cy.get('a[data-cy="dom-link"]').click() - cy.title() + cy.url().then((url) => { + expect(url).to.equal('http://www.foobar.com:3500/fixtures/dom.html') + }) }) - it('.url()', (done) => { - cy.on('fail', (err) => { - assertOriginFailure(err, done) + it('.hash()', () => { + cy.get('a[data-cy="dom-link"]').click() + cy.hash().then((hash) => { + expect(hash).to.equal('') }) + }) + it('.location()', () => { cy.get('a[data-cy="dom-link"]').click() - cy.url() + cy.location().then((loc) => { + expect(loc.href).to.equal('http://www.foobar.com:3500/fixtures/dom.html') + }) }) - it('.hash()', (done) => { - cy.on('fail', (err) => { - assertOriginFailure(err, done) + it('.title()', () => { + cy.get('a[data-cy="dom-link"]').click() + cy.title().then((title) => { + expect(title).to.equal('DOM Fixture') }) + }) + it('.go()', () => { cy.get('a[data-cy="dom-link"]').click() - cy.hash() + cy.go('back') }) + }) - it('.location()', (done) => { - cy.on('fail', (err) => { - assertOriginFailure(err, done) - }) + context('cross-origin AUT errors', () => { + // We only need to check .get here because the other commands are chained off of it. + // the exceptions are document() and scrollTo() + const assertOriginFailure = (err: Error, done: () => void) => { + expect(err.message).to.include(`The command was expected to run against origin \`http://localhost:3500\` but the application is at origin \`http://www.foobar.com:3500\`.`) + expect(err.message).to.include(`This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly.`) + expect(err.message).to.include(`Using \`cy.origin()\` to wrap the commands run on \`http://www.foobar.com:3500\` will likely fix this issue.`) + expect(err.message).to.include(`cy.origin('http://www.foobar.com:3500', () => {\`\n\` \`\n\`})`) - cy.get('a[data-cy="dom-link"]').click() - cy.location() - }) + // make sure that the secondary origin failures do NOT show up as spec failures or AUT failures + expect(err.message).not.to.include(`The following error originated from your test code, not from Cypress`) + expect(err.message).not.to.include(`The following error originated from your application code, not from Cypress`) + done() + } - it('.go()', (done) => { + it('.get()', { defaultCommandTimeout: 50 }, (done) => { cy.on('fail', (err) => { + expect(err.message).to.include(`Timed out retrying after 50ms:`) assertOriginFailure(err, done) }) cy.get('a[data-cy="dom-link"]').click() - cy.go('back') + cy.get('#button') }) - it('.reload()', (done) => { + it('.document()', (done) => { cy.on('fail', (err) => { assertOriginFailure(err, done) }) cy.get('a[data-cy="dom-link"]').click() - cy.reload() + cy.document() }) it('.scrollTo()', (done) => { diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts index e410f56723bc..9325a2d6ea3b 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts @@ -62,19 +62,16 @@ context('cy.origin location', { browser: '!webkit' }, () => { expect(consoleProps.name).to.equal('location') expect(consoleProps.type).to.equal('command') - expect(consoleProps.props.Yielded).to.have.property('auth').that.is.a('string') - expect(consoleProps.props.Yielded).to.have.property('authObj').that.is.undefined expect(consoleProps.props.Yielded).to.have.property('hash').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('host').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('hostname').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('href').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('origin').that.is.a('string') - expect(consoleProps.props.Yielded).to.have.property('superDomainOrigin').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('pathname').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('port').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('protocol').that.is.a('string') expect(consoleProps.props.Yielded).to.have.property('search').that.is.a('string') - expect(consoleProps.props.Yielded).to.have.property('superDomain').that.is.a('string') + expect(consoleProps.props.Yielded).to.have.property('searchParams').that.is.an('object') }) }) diff --git a/packages/driver/src/cy/commands/helpers/location.ts b/packages/driver/src/cy/commands/helpers/location.ts new file mode 100644 index 000000000000..e82da1c25987 --- /dev/null +++ b/packages/driver/src/cy/commands/helpers/location.ts @@ -0,0 +1,72 @@ +export class UrlNotYetAvailableError extends Error { + constructor () { + const message = 'URL is not yet available' + + super(message) + this.name = 'UrlNotYetAvailableError' + } +} + +export function getUrlFromAutomation (Cypress: Cypress.Cypress, options: Partial = {}) { + const timeout = options.timeout ?? Cypress.config('defaultCommandTimeout') as number + + this.set('timeout', timeout) + + let fullUrlObj: any = null + let automationPromise: Promise | null = null + // need to set a valid type on this + let mostRecentError = new UrlNotYetAvailableError() + + const getUrlFromAutomation = () => { + if (automationPromise) { + return automationPromise + } + + fullUrlObj = null + + automationPromise = Cypress.automation('get:aut:url', {}) + .timeout(timeout) + .then((url) => { + const fullUrlObject = new URL(url) + + fullUrlObj = { + hash: fullUrlObject.hash, + host: fullUrlObject.host, + hostname: fullUrlObject.hostname, + href: fullUrlObject.href, + origin: fullUrlObject.origin, + pathname: fullUrlObject.pathname, + port: fullUrlObject.port, + protocol: fullUrlObject.protocol, + search: fullUrlObject.search, + searchParams: fullUrlObject.searchParams, + } + }) + .catch((err) => mostRecentError = err) + // Pass or fail, we always clear the automationPromise, so future retries know there's no live request to the server. + .finally(() => automationPromise = null) + + return automationPromise + } + + this.set('onFail', (err) => { + // if we are actively retrying or the assertion failed, we want to retry + if (err.name === 'UrlNotYetAvailableError' || err.name === 'AssertionError') { + // tslint:disable-next-line no-floating-promises + getUrlFromAutomation() + } else { + throw err + } + }) + + return () => { + if (fullUrlObj) { + return fullUrlObj + } + + // tslint:disable-next-line no-floating-promises + getUrlFromAutomation() + + throw mostRecentError + } +} diff --git a/packages/driver/src/cy/commands/helpers/window.ts b/packages/driver/src/cy/commands/helpers/window.ts new file mode 100644 index 000000000000..6ae8c15fd8a5 --- /dev/null +++ b/packages/driver/src/cy/commands/helpers/window.ts @@ -0,0 +1,59 @@ +export class TitleNotYetAvailableError extends Error { + constructor () { + const message = 'document.title is not yet available' + + super(message) + this.name = 'TitleNotYetAvailableError' + } +} + +export function getTitleFromAutomation (Cypress: Cypress.Cypress, options: Partial = {}) { + const timeout = options.timeout ?? Cypress.config('defaultCommandTimeout') as number + + this.set('timeout', timeout) + + let documentTitle: any = null + let automationPromise: Promise | null = null + // need to set a valid type on this + let mostRecentError = new TitleNotYetAvailableError() + + const getTitleFromAutomation = () => { + if (automationPromise) { + return automationPromise + } + + documentTitle = null + + automationPromise = Cypress.automation('get:aut:title', {}) + .timeout(timeout) + .then((returnedDocumentTitle) => { + documentTitle = returnedDocumentTitle + }) + .catch((err) => mostRecentError = err) + // Pass or fail, we always clear the automationPromise, so future retries know there's no live request to the server. + .finally(() => automationPromise = null) + + return automationPromise + } + + this.set('onFail', (err, timedOut) => { + // if we are actively retrying or the assertion failed, we want to retry + if (err.name === 'TitleNotYetAvailableError' || err.name === 'AssertionError') { + // tslint:disable-next-line no-floating-promises + getTitleFromAutomation() + } else { + throw err + } + }) + + return () => { + if (documentTitle !== null) { + return documentTitle + } + + // tslint:disable-next-line no-floating-promises + getTitleFromAutomation() + + throw mostRecentError + } +} diff --git a/packages/driver/src/cy/commands/location.ts b/packages/driver/src/cy/commands/location.ts index d68d93eb59aa..43fdeb905423 100644 --- a/packages/driver/src/cy/commands/location.ts +++ b/packages/driver/src/cy/commands/location.ts @@ -1,68 +1,126 @@ import _ from 'lodash' import $errUtils from '../../cypress/error_utils' +import { getUrlFromAutomation } from './helpers/location' -export default (Commands, Cypress, cy) => { - Commands.addQuery('url', function url (options: Partial = {}) { +export function urlQueryCommand (Cypress: Cypress.Cypress, cy: Cypress.Cypress, options: Partial = {}) { + Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) + + // Since webkit doesn't have an automation client and doesn't support cy.origin(), we need to use the legacy method to get the url + if (Cypress.isBrowser('webkit')) { // Make sure the url command can communicate with the AUT. // otherwise, it yields an empty string + // @ts-expect-error Cypress.ensure.commandCanCommunicateWithAUT(cy) this.set('timeout', options.timeout) - Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) - return () => { + // @ts-expect-error const href = cy.getRemoteLocation('href') return options.decode ? decodeURI(href) : href } - }) + } + + const fn = getUrlFromAutomation.bind(this)(Cypress, options) + + return () => { + const fullUrlObj = fn() + + if (fullUrlObj) { + const href = fullUrlObj.href + + return options.decode ? decodeURI(href) : href + } + } +} + +export function hashQueryCommand (Cypress: Cypress.Cypress, cy: Cypress.Cypress, options: Partial = {}) { + Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) - Commands.addQuery('hash', function url (options: Partial = {}) { + // Since webkit doesn't have an automation client and doesn't support cy.origin(), we need to use the legacy method to get the hash + if (Cypress.isBrowser('webkit')) { // Make sure the hash command can communicate with the AUT. + // @ts-expect-error Cypress.ensure.commandCanCommunicateWithAUT(cy) this.set('timeout', options.timeout) - Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) - + // @ts-expect-error return () => cy.getRemoteLocation('hash') + } + + const fn = getUrlFromAutomation.bind(this)(Cypress, options) + + return () => { + const fullUrlObj = fn() + + if (fullUrlObj) { + return fullUrlObj.hash + } + } +} + +export function locationQueryCommand (Cypress: Cypress.Cypress, cy: Cypress.Cypress, key: string, options: Partial = {}) { + // normalize arguments allowing key + options to be undefined + // key can represent the options + + // Make sure the location command can communicate with the AUT. + // otherwise the command just yields 'null' and the reason may be unclear to the user. + if (_.isObject(key)) { + options = key + } + + Cypress.log({ + message: _.isString(key) ? key : '', + hidden: options.log === false, + timeout: options.timeout, }) - Commands.addQuery('location', function location (key, options: Partial = {}) { + // Since webkit doesn't have an automation client and doesn't support cy.origin(), we need to use the legacy method to get the location + if (Cypress.isBrowser('webkit')) { // normalize arguments allowing key + options to be undefined // key can represent the options // Make sure the location command can communicate with the AUT. // otherwise the command just yields 'null' and the reason may be unclear to the user. + //@ts-expect-error Cypress.ensure.commandCanCommunicateWithAUT(cy) - if (_.isObject(key)) { - options = key - } this.set('timeout', options.timeout) + } - Cypress.log({ - message: _.isString(key) ? key : '', - hidden: options.log === false, - timeout: options.timeout, - }) + //@ts-expect-error + const fn = Cypress.isBrowser('webkit') ? cy.getRemoteLocation : getUrlFromAutomation.bind(this)(Cypress, options) - return () => { - const location = cy.getRemoteLocation() - - if (location === '') { - // maybe the page's domain is "invisible" to us - // and we cannot get the location. Return null - // so the command keeps retrying, maybe there is - // a redirect that puts us on the domain we can access - return null - } - - return _.isString(key) - // use existential here because we only want to throw - // on null or undefined values (and not empty strings) - ? location[key] ?? $errUtils.throwErrByPath('location.invalid_key', { args: { key } }) - : location + return () => { + const location = fn() + + if (location === '') { + // maybe the page's domain is "invisible" to us + // and we cannot get the location. Return null + // so the command keeps retrying, maybe there is + // a redirect that puts us on the domain we can access + return null } + + return _.isString(key) + // use existential here because we only want to throw + // on null or undefined values (and not empty strings) + ? location[key] ?? $errUtils.throwErrByPath('location.invalid_key', { args: { key } }) + : location + } +} + +export default (Commands, Cypress, cy) => { + Commands.addQuery('url', function (options: Partial = {}) { + return urlQueryCommand.call(this, Cypress, cy, options) + }) + + Commands.addQuery('hash', function (options: Partial = {}) { + return hashQueryCommand.call(this, Cypress, cy, options) + }) + + Commands.addQuery('location', function (key: string, options: Partial = {}) { + return locationQueryCommand.call(this, Cypress, cy, key, options) }) } diff --git a/packages/driver/src/cy/commands/navigation.ts b/packages/driver/src/cy/commands/navigation.ts index 496d672409c0..4ff70ec2f6a9 100644 --- a/packages/driver/src/cy/commands/navigation.ts +++ b/packages/driver/src/cy/commands/navigation.ts @@ -9,10 +9,9 @@ import { bothUrlsMatchAndOneHasHash } from '../navigation' import { $Location, LocationObject } from '../../cypress/location' import { isRunnerAbleToCommunicateWithAut } from '../../util/commandAUTCommunication' import { whatIsCircular } from '../../util/what-is-circular' - -import type { RunState } from '@packages/types' - import debugFn from 'debug' +import type { RunState } from '@packages/types' +import type { StateFunc } from '../../cypress/state' const debug = debugFn('cypress:driver:navigation') let id = null @@ -444,6 +443,218 @@ interface InternalVisitOptions extends Partial { hasAlreadyVisitedUrl: boolean } +export const reload = (Cypress: Cypress.Cypress, cy: Cypress.Cypress, state: StateFunc, config: Cypress.Config, args: any[]) => { + let forceReload + let userOptions + const throwArgsErr = () => { + $errUtils.throwErrByPath('reload.invalid_arguments') + } + + switch (args.length) { + case 0: + forceReload = false + userOptions = {} + break + + case 1: + if (_.isObject(args[0])) { + userOptions = args[0] + } else { + forceReload = args[0] + } + + break + + case 2: + forceReload = args[0] + userOptions = args[1] + break + + default: + throwArgsErr() + } + + // clear the current timeout + // @ts-expect-error + cy.clearTimeout('reload') + + let cleanup: (() => any) | null = null + const options = _.defaults({}, userOptions, { + log: true, + // @ts-expect-error + timeout: config('pageLoadTimeout'), + }) + + const reload = () => { + return new Promise((resolve) => { + forceReload = forceReload || false + userOptions = userOptions || {} + + if (!_.isObject(userOptions)) { + throwArgsErr() + } + + if (!_.isBoolean(forceReload)) { + throwArgsErr() + } + + options._log = Cypress.log({ timeout: options.timeout, hidden: options.log === false }) + + options._log?.snapshot('before', { next: 'after' }) + + cleanup = () => { + knownCommandCausedInstability = false + + return cy.removeListener('window:load', resolve) + } + + knownCommandCausedInstability = true + + cy.once('window:load', resolve) + + // Since webkit doesn't have an automation client and doesn't support cy.origin(), we need to use the legacy method to reload the page + return Cypress.isBrowser('webkit') ? $utils.locReload(forceReload, state('window')) : Cypress.automation('reload:aut:frame', { + forceReload, + }) + }) + } + + return reload() + .timeout(options.timeout, 'reload') + .catch(Promise.TimeoutError, () => { + return timedOutWaitingForPageLoad(options.timeout, options._log) + }) + .finally(() => { + if (typeof cleanup === 'function') { + cleanup() + } + + if (Cypress.isBrowser('webkit')) { + // Make sure the reload command can communicate with the AUT. + // if we failed for any other reason, we need to display the correct error to the user. + // @ts-expect-error + Cypress.ensure.commandCanCommunicateWithAUT(cy) + } + + return null + }) +} + +export const go = (Cypress: Cypress.Cypress, cy: Cypress.Cypress, state: StateFunc, config: Cypress.Config, numberOrString: number | string, userOptions = {}) => { + const options: Record = _.defaults({}, userOptions, { + log: true, + // @ts-expect-error + timeout: config('pageLoadTimeout'), + }) + + options._log = Cypress.log({ timeout: options.timeout, hidden: options.log === false }) + + const goNumber = (num) => { + if (num === 0) { + $errUtils.throwErrByPath('go.invalid_number', { onFail: options._log }) + } + + let cleanup: (() => any) | null = null + + if (options._log) { + options._log.snapshot('before', { next: 'after' }) + } + + const go = () => { + return Promise.try(() => { + let didUnload = false + + const beforeUnload = () => { + didUnload = true + } + + // clear the current timeout + // @ts-expect-error + cy.clearTimeout() + + cy.once('window:before:unload', beforeUnload) + + const didLoad = new Promise((resolve) => { + cleanup = function () { + cy.removeListener('window:load', resolve) + + return cy.removeListener('window:before:unload', beforeUnload) + } + + return cy.once('window:load', resolve) + }) + + knownCommandCausedInstability = true + + // need to set the attributes of 'go' + // consoleProps here with win + // make sure we resolve our go function + // with the remove window (just like cy.visit) + const retWin = () => state('window') + + // Since webkit doesn't have an automation client and doesn't support cy.origin(), we need to use the legacy method to navigate the history + Cypress.isBrowser('webkit') ? state('window').history.go(num) : Cypress.automation('navigate:aut:history', { historyNumber: num }) + + Promise + .delay(100) + .then(() => { + knownCommandCausedInstability = false + + // if we've didUnload then we know we're + // doing a full page refresh and we need + // to wait until + if (didUnload) { + return didLoad.then(retWin) + } + + return retWin() + }) + }) + } + + return go() + .timeout(options.timeout, 'go') + .catch(Promise.TimeoutError, () => { + return timedOutWaitingForPageLoad(options.timeout, options._log) + }).finally(() => { + if (typeof cleanup === 'function') { + cleanup() + } + + if (Cypress.isBrowser('webkit')) { + // Make sure the reload command can communicate with the AUT. + // if we failed for any other reason, we need to display the correct error to the user. + // @ts-expect-error + Cypress.ensure.commandCanCommunicateWithAUT(cy) + } + + return null + }) + } + + const goString = (str) => { + switch (str) { + case 'forward': return goNumber(1) + case 'back': return goNumber(-1) + default: + return $errUtils.throwErrByPath('go.invalid_direction', { + onFail: options._log, + args: { str }, + }) + } + } + + if (_.isFinite(numberOrString)) { + return goNumber(numberOrString) + } + + if (_.isString(numberOrString)) { + return goString(numberOrString) + } + + return $errUtils.throwErrByPath('go.invalid_argument', { onFail: options._log }) +} + export default (Commands, Cypress, cy, state, config) => { reset() @@ -536,202 +747,11 @@ export default (Commands, Cypress, cy, state, config) => { Commands.addAll({ reload (...args) { - let forceReload - let userOptions - const throwArgsErr = () => { - $errUtils.throwErrByPath('reload.invalid_arguments') - } - - switch (args.length) { - case 0: - forceReload = false - userOptions = {} - break - - case 1: - if (_.isObject(args[0])) { - userOptions = args[0] - } else { - forceReload = args[0] - } - - break - - case 2: - forceReload = args[0] - userOptions = args[1] - break - - default: - throwArgsErr() - } - - // clear the current timeout - cy.clearTimeout('reload') - - let cleanup: (() => any) | null = null - const options = _.defaults({}, userOptions, { - log: true, - timeout: config('pageLoadTimeout'), - }) - - const reload = () => { - return new Promise((resolve) => { - forceReload = forceReload || false - userOptions = userOptions || {} - - if (!_.isObject(userOptions)) { - throwArgsErr() - } - - if (!_.isBoolean(forceReload)) { - throwArgsErr() - } - - options._log = Cypress.log({ timeout: options.timeout, hidden: options.log === false }) - - options._log?.snapshot('before', { next: 'after' }) - - cleanup = () => { - knownCommandCausedInstability = false - - return cy.removeListener('window:load', resolve) - } - - knownCommandCausedInstability = true - - cy.once('window:load', resolve) - - return $utils.locReload(forceReload, state('window')) - }) - } - - return reload() - .timeout(options.timeout, 'reload') - .catch(Promise.TimeoutError, () => { - return timedOutWaitingForPageLoad(options.timeout, options._log) - }) - .finally(() => { - if (typeof cleanup === 'function') { - cleanup() - } - - // Make sure the reload command can communicate with the AUT. - // if we failed for any other reason, we need to display the correct error to the user. - Cypress.ensure.commandCanCommunicateWithAUT(cy) - - return null - }) + return reload.call(this, Cypress, cy, state, config, args) }, go (numberOrString, userOptions = {}) { - const options: Record = _.defaults({}, userOptions, { - log: true, - timeout: config('pageLoadTimeout'), - }) - - options._log = Cypress.log({ timeout: options.timeout, hidden: options.log === false }) - - const win = state('window') - - const goNumber = (num) => { - if (num === 0) { - $errUtils.throwErrByPath('go.invalid_number', { onFail: options._log }) - } - - let cleanup: (() => any) | null = null - - if (options._log) { - options._log.snapshot('before', { next: 'after' }) - } - - const go = () => { - return Promise.try(() => { - let didUnload = false - - const beforeUnload = () => { - didUnload = true - } - - // clear the current timeout - cy.clearTimeout() - - cy.once('window:before:unload', beforeUnload) - - const didLoad = new Promise((resolve) => { - cleanup = function () { - cy.removeListener('window:load', resolve) - - return cy.removeListener('window:before:unload', beforeUnload) - } - - return cy.once('window:load', resolve) - }) - - knownCommandCausedInstability = true - - win.history.go(num) - - // need to set the attributes of 'go' - // consoleProps here with win - // make sure we resolve our go function - // with the remove window (just like cy.visit) - const retWin = () => state('window') - - return Promise - .delay(100) - .then(() => { - knownCommandCausedInstability = false - - // if we've didUnload then we know we're - // doing a full page refresh and we need - // to wait until - if (didUnload) { - return didLoad.then(retWin) - } - - return retWin() - }) - }) - } - - return go() - .timeout(options.timeout, 'go') - .catch(Promise.TimeoutError, () => { - return timedOutWaitingForPageLoad(options.timeout, options._log) - }).finally(() => { - if (typeof cleanup === 'function') { - cleanup() - } - - // Make sure the go command can communicate with the AUT. - Cypress.ensure.commandCanCommunicateWithAUT(cy) - - return null - }) - } - - const goString = (str) => { - switch (str) { - case 'forward': return goNumber(1) - case 'back': return goNumber(-1) - default: - return $errUtils.throwErrByPath('go.invalid_direction', { - onFail: options._log, - args: { str }, - }) - } - } - - if (_.isFinite(numberOrString)) { - return goNumber(numberOrString) - } - - if (_.isString(numberOrString)) { - return goString(numberOrString) - } - - return $errUtils.throwErrByPath('go.invalid_argument', { onFail: options._log }) + return go.call(this, Cypress, cy, state, config, numberOrString, userOptions) }, visit (url, userOptions: Partial = {}) { diff --git a/packages/driver/src/cy/commands/window.ts b/packages/driver/src/cy/commands/window.ts index a42ef1b98472..5af100d26d78 100644 --- a/packages/driver/src/cy/commands/window.ts +++ b/packages/driver/src/cy/commands/window.ts @@ -3,6 +3,9 @@ import Promise from 'bluebird' import $errUtils from '../../cypress/error_utils' import type { Log } from '../../cypress/log' +import { getTitleFromAutomation } from './helpers/window' +import type { StateFunc } from '../../cypress/state' +import type { $Cy } from '../../cypress/cy' const viewports = { 'macbook-16': '1536x960', @@ -40,6 +43,24 @@ interface InternalViewportOptions extends Partial { _log?: Log } +export function getTitleQueryCommand (Cypress: Cypress.Cypress, cy: $Cy, state: StateFunc, options: Partial = {}) { + Cypress.log({ timeout: options.timeout, hidden: options.log === false }) + + // Since webkit doesn't have an automation client and doesn't support cy.origin(), we need to use the legacy method to get the title + if (Cypress.isBrowser('webkit')) { + this.set('timeout', options.timeout) + + // Make sure the window command can communicate with the AUT. + // otherwise, it yields an empty string + //@ts-expect-error + Cypress.ensure.commandCanCommunicateWithAUT(cy) + + return () => (state('document')?.title || '') + } + + return getTitleFromAutomation.bind(this)(Cypress, options) +} + export default (Commands, Cypress, cy, state) => { const defaultViewport: CurrentViewport = _.pick(Cypress.config() as Cypress.Config, 'viewportWidth', 'viewportHeight') @@ -88,19 +109,12 @@ export default (Commands, Cypress, cy, state) => { }) } - Commands.addQuery('title', function title (options: Partial = {}) { - // Make sure the window command can communicate with the AUT. - // otherwise, it yields an empty string - Cypress.ensure.commandCanCommunicateWithAUT(cy) - this.set('timeout', options.timeout) - Cypress.log({ timeout: options.timeout, hidden: options.log === false }) - - return () => (state('document')?.title || '') + Commands.addQuery('title', function (options: Partial = {}) { + return getTitleQueryCommand.call(this, Cypress, cy, state, options) }) Commands.addQuery('window', function windowFn (options: Partial = {}) { // Make sure the window command can communicate with the AUT. - Cypress.ensure.commandCanCommunicateWithAUT(cy) this.set('timeout', options.timeout) Cypress.log({ hidden: options.log === false, diff --git a/packages/driver/test/unit/cy/commands/helpers/location.spec.ts b/packages/driver/test/unit/cy/commands/helpers/location.spec.ts new file mode 100644 index 000000000000..8165017c71a2 --- /dev/null +++ b/packages/driver/test/unit/cy/commands/helpers/location.spec.ts @@ -0,0 +1,276 @@ +/** + * @vitest-environment jsdom + */ +import { vi, describe, it, expect, beforeEach, Mock, MockedObject } from 'vitest' +import { getUrlFromAutomation, UrlNotYetAvailableError } from '../../../../../src/cy/commands/helpers/location' +import Bluebird from 'bluebird' + +const flushPromises = () => { + return new Promise((resolve) => { + setTimeout(resolve) + }) +} + +describe('cy/commands/helpers/location', () => { + let log: Mock + let mockCypress: MockedObject + let mockLogReturnValue: Cypress.Log + let mockContext: MockedObject + + beforeEach(() => { + log = vi.fn() + + mockCypress = { + // The overloads for `log` don't get applied correctly here + log, + automation: vi.fn(), + // @ts-expect-error - Mock Cypress config object doesn't have all required properties + config: vi.fn(), + } + + mockLogReturnValue = { + id: 'log_id', + end: vi.fn(), + error: vi.fn(), + finish: vi.fn(), + get: vi.fn(), + set: vi.fn(), + snapshot: vi.fn(), + _hasInitiallyLogged: false, + groupEnd: vi.fn(), + } + + mockCypress.log.mockReturnValue(mockLogReturnValue) + + mockContext = { + set: vi.fn(), + } + }) + + describe('getUrlFromAutomation', () => { + describe('options', () => { + it('sets correct timeout option if passed in', async () => { + getUrlFromAutomation.call(mockContext, mockCypress, { + timeout: 2000, + }) + + expect(mockContext.set).toHaveBeenCalledWith('timeout', 2000) + }) + + it('otherwise sets timeout to defaultCommandTimeout', async () => { + mockCypress.config.mockImplementation((key) => { + // @ts-expect-error + if (key === 'defaultCommandTimeout') { + return 1000 + } + + return undefined + }) + + getUrlFromAutomation.call(mockContext, mockCypress, {}) + + expect(mockCypress.config).toHaveBeenCalledWith('defaultCommandTimeout') + expect(mockContext.set).toHaveBeenCalledWith('timeout', 1000) + }) + }) + + describe('leveraging the automation client', () => { + let mockOptions: Cypress.Loggable & Cypress.Timeoutable + + beforeEach(() => { + mockOptions = { + timeout: 1000, + log: false, + } + }) + + it('throws an error when the automation promise has not yet resolved', async () => { + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => undefined) + }) + + const fn = getUrlFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow('URL is not yet available') + + expect(mockCypress.automation).toHaveBeenCalledWith('get:aut:url', {}) + }) + + it('returns the url object when the automation promise is resolved', async () => { + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve('https://www.example.com#foobar')) + }) + + const fn = getUrlFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + const url = fn() + + expect(url).toEqual({ + protocol: 'https:', + host: 'www.example.com', + hostname: 'www.example.com', + hash: '#foobar', + search: '', + pathname: '/', + port: '', + origin: 'https://www.example.com', + href: 'https://www.example.com/#foobar', + searchParams: expect.any(Object), + }) + }) + + it('throws an error when the automation promise is rejected and propagates the error', async () => { + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve, reject) => reject(new Error('The automation client threw an error'))) + }) + + const fn = getUrlFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + fn() + }).toThrow('The automation client threw an error') + }) + + describe('onFail', () => { + it('retries when the onFail handler is called with a UrlNotYetAvailableError error', async () => { + // when calling the onFail handler with acceptable errors, we will be retrying the automation client + // for this test, the automation client will be called twice + let automationCallCount = 0 + + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + automationCallCount++ + + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve()) + }) + + let onFailHandler: any + + mockContext.set.mockImplementation((key, value) => { + if (key === 'onFail') { + onFailHandler = value + } + }) + + const fn = getUrlFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + onFailHandler(new UrlNotYetAvailableError()) + }).not.toThrow() + + expect(automationCallCount).toBe(2) + }) + + it('retries when the onFail handler is called with a UrlNotYetAvailableError error', async () => { + // when calling the onFail handler with acceptable errors, we will be retrying the automation client + // for this test, the automation client will be called twice + let automationCallCount = 0 + + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + automationCallCount++ + + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve()) + }) + + let onFailHandler: any + + mockContext.set.mockImplementation((key, value) => { + if (key === 'onFail') { + onFailHandler = value + } + }) + + const fn = getUrlFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + const mockAssertionError = new Error('The assertion failed') + + mockAssertionError.name = 'AssertionError' + + onFailHandler(mockAssertionError) + }).not.toThrow() + + expect(automationCallCount).toBe(2) + }) + + it('fails when the onFail handler is called with an error that is not a UrlNotYetAvailableError or AssertionError', async () => { + // when calling the onFail handler with unacceptable errrors, we will not be retrying the automation client + // for this test, the automation client will be called once + let automationCallCount = 0 + + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + automationCallCount++ + + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve()) + }) + + let onFailHandler: any + + mockContext.set.mockImplementation((key, value) => { + if (key === 'onFail') { + onFailHandler = value + } + }) + + const fn = getUrlFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + const mockAssertionError = new Error('Something else') + + onFailHandler(mockAssertionError) + }).toThrow('Something else') + + expect(automationCallCount).toBe(1) + }) + }) + }) + }) +}) diff --git a/packages/driver/test/unit/cy/commands/helpers/window.spec.ts b/packages/driver/test/unit/cy/commands/helpers/window.spec.ts new file mode 100644 index 000000000000..8a2794d1dda1 --- /dev/null +++ b/packages/driver/test/unit/cy/commands/helpers/window.spec.ts @@ -0,0 +1,265 @@ +/** + * @vitest-environment jsdom + */ +import { vi, describe, it, expect, beforeEach, Mock, MockedObject } from 'vitest' +import { getTitleFromAutomation, TitleNotYetAvailableError } from '../../../../../src/cy/commands/helpers/window' +import Bluebird from 'bluebird' + +const flushPromises = () => { + return new Promise((resolve) => { + setTimeout(resolve) + }) +} + +describe('cy/commands/helpers/windows', () => { + let log: Mock + let mockCypress: MockedObject + let mockLogReturnValue: Cypress.Log + let mockContext: MockedObject + + beforeEach(() => { + log = vi.fn() + + mockCypress = { + // The overloads for `log` don't get applied correctly here + log, + automation: vi.fn(), + // @ts-expect-error - Mock Cypress config object doesn't have all required properties + config: vi.fn(), + } + + mockLogReturnValue = { + id: 'log_id', + end: vi.fn(), + error: vi.fn(), + finish: vi.fn(), + get: vi.fn(), + set: vi.fn(), + snapshot: vi.fn(), + _hasInitiallyLogged: false, + groupEnd: vi.fn(), + } + + mockCypress.log.mockReturnValue(mockLogReturnValue) + + mockContext = { + set: vi.fn(), + } + }) + + describe('getTitleFromAutomation', () => { + describe('options', () => { + it('sets correct timeout option if passed in', async () => { + getTitleFromAutomation.call(mockContext, mockCypress, { + timeout: 2000, + }) + + expect(mockContext.set).toHaveBeenCalledWith('timeout', 2000) + }) + + it('otherwise sets timeout to defaultCommandTimeout', async () => { + mockCypress.config.mockImplementation((key) => { + // @ts-expect-error + if (key === 'defaultCommandTimeout') { + return 1000 + } + + return undefined + }) + + getTitleFromAutomation.call(mockContext, mockCypress, {}) + + expect(mockCypress.config).toHaveBeenCalledWith('defaultCommandTimeout') + expect(mockContext.set).toHaveBeenCalledWith('timeout', 1000) + }) + }) + + describe('leveraging the automation client', () => { + let mockOptions: Cypress.Loggable & Cypress.Timeoutable + + beforeEach(() => { + mockOptions = { + timeout: 1000, + log: false, + } + }) + + it('throws an error when the automation promise has not yet resolved', async () => { + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => undefined) + }) + + const fn = getTitleFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow('document.title is not yet available') + + expect(mockCypress.automation).toHaveBeenCalledWith('get:aut:title', {}) + }) + + it('returns the document\'s title when the automation promise is resolved', async () => { + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve('This is the frame title')) + }) + + const fn = getTitleFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + const title = fn() + + expect(title).toEqual('This is the frame title') + }) + + it('throws an error when the automation promise is rejected and propagates the error', async () => { + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve, reject) => reject(new Error('The automation client threw an error'))) + }) + + const fn = getTitleFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + fn() + }).toThrow('The automation client threw an error') + }) + + describe('onFail', () => { + it('retries when the onFail handler is called with a TitleNotYetAvailableError error', async () => { + // when calling the onFail handler with acceptable errors, we will be retrying the automation client + // for this test, the automation client will be called twice + let automationCallCount = 0 + + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + automationCallCount++ + + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve()) + }) + + let onFailHandler: any + + mockContext.set.mockImplementation((key, value) => { + if (key === 'onFail') { + onFailHandler = value + } + }) + + const fn = getTitleFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + onFailHandler(new TitleNotYetAvailableError()) + }).not.toThrow() + + expect(automationCallCount).toBe(2) + }) + + it('retries when the onFail handler is called with a TitleNotYetAvailableError error', async () => { + // when calling the onFail handler with acceptable errors, we will be retrying the automation client + // for this test, the automation client will be called twice + let automationCallCount = 0 + + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + automationCallCount++ + + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve()) + }) + + let onFailHandler: any + + mockContext.set.mockImplementation((key, value) => { + if (key === 'onFail') { + onFailHandler = value + } + }) + + const fn = getTitleFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + const mockAssertionError = new Error('The assertion failed') + + mockAssertionError.name = 'AssertionError' + + onFailHandler(mockAssertionError) + }).not.toThrow() + + expect(automationCallCount).toBe(2) + }) + + it('fails when the onFail handler is called with an error that is not a TitleNotYetAvailableError or AssertionError', async () => { + // when calling the onFail handler with unacceptable errrors, we will not be retrying the automation client + // for this test, the automation client will be called once + let automationCallCount = 0 + + // @ts-expect-error + mockCypress.automation.mockImplementation(() => { + automationCallCount++ + + // no-op promise to simulate the waiting for the automation client + return new Bluebird.Promise((resolve) => resolve()) + }) + + let onFailHandler: any + + mockContext.set.mockImplementation((key, value) => { + if (key === 'onFail') { + onFailHandler = value + } + }) + + const fn = getTitleFromAutomation.call(mockContext, mockCypress, mockOptions) + + expect(() => { + fn() + }).toThrow() + + // flush the microtask queue so we have a url value next time we call fn() + await flushPromises() + + expect(() => { + const mockAssertionError = new Error('Something else') + + onFailHandler(mockAssertionError) + }).toThrow('Something else') + + expect(automationCallCount).toBe(1) + }) + }) + }) + }) +}) diff --git a/packages/driver/test/unit/cy/commands/location.spec.ts b/packages/driver/test/unit/cy/commands/location.spec.ts new file mode 100644 index 000000000000..8546e0dd9991 --- /dev/null +++ b/packages/driver/test/unit/cy/commands/location.spec.ts @@ -0,0 +1,374 @@ +/** + * @vitest-environment jsdom + */ +import { vi, describe, it, expect, beforeEach, MockedObject } from 'vitest' +import { urlQueryCommand, hashQueryCommand, locationQueryCommand } from '../../../../src/cy/commands/location' +import { getUrlFromAutomation } from '../../../../src/cy/commands/helpers/location' +import type { $Cy } from '../../../../src/cypress/cy' + +vi.mock('../../../../src/cy/commands/helpers/location', async () => { + return { + getUrlFromAutomation: vi.fn(), + } +}) + +describe('cy/commands/location', () => { + let mockCypress: MockedObject + let mockCy: MockedObject<$Cy> + let mockContext: MockedObject + + beforeEach(() => { + mockCypress = { + log: vi.fn(), + automation: vi.fn(), + isBrowser: vi.fn(), + ensure: { + // @ts-expect-error + commandCanCommunicateWithAUT: vi.fn(), + }, + // @ts-expect-error + config: vi.fn(), + } + + // @ts-expect-error + mockCy = { + getRemoteLocation: vi.fn(), + } + + mockContext = { + set: vi.fn(), + } + + //@ts-expect-error + getUrlFromAutomation.mockReset() + }) + + describe('url', () => { + describe('chromium/firefox', () => { + it('returns the url href from the automation client', () => { + // @ts-expect-error + getUrlFromAutomation.mockReturnValue(() => { + return { + href: 'https://www.example.com/#foobar', + } + }) + + const url = urlQueryCommand.call(mockContext, mockCypress, mockCy, {})() + + expect(url).toBe('https://www.example.com/#foobar') + + expect(getUrlFromAutomation).toHaveBeenCalledOnce() + + expect(mockCy.getRemoteLocation).not.toHaveBeenCalled() + }) + + it('supports the decode option', () => { + // @ts-expect-error + getUrlFromAutomation.mockReturnValue(() => { + return { + href: 'https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B', + } + }) + + const url = urlQueryCommand.call(mockContext, mockCypress, mockCy, { + decode: true, + })() + + expect(url).toBe('https://mozilla.org/?x=шеллы') + + expect(getUrlFromAutomation).toHaveBeenCalledOnce() + }) + }) + + describe('webkit', () => { + beforeEach(() => { + mockCypress.isBrowser.mockImplementation((browserName) => { + if (browserName === 'webkit') { + return true + } + + return false + }) + }) + + it('does not use the automation client if the browser is webkit', () => { + mockCy.getRemoteLocation.mockImplementation(() => { + return 'https://www.example.com/#foobar' + }) + + const url = urlQueryCommand.call(mockContext, mockCypress, mockCy, { timeout: 10000 })() + + expect(url).toBe('https://www.example.com/#foobar') + + // @ts-expect-error + expect(mockCypress.ensure.commandCanCommunicateWithAUT).toHaveBeenCalledOnce() + + expect(mockContext.set).toHaveBeenCalledWith('timeout', 10000) + + expect(getUrlFromAutomation).not.toHaveBeenCalled() + + expect(mockCy.getRemoteLocation).toHaveBeenCalledWith('href') + }) + + it('supports the decode option', () => { + mockCy.getRemoteLocation.mockImplementation(() => { + return 'https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B' + }) + + const url = urlQueryCommand.call(mockContext, mockCypress, mockCy, { + decode: true, + })() + + expect(url).toBe('https://mozilla.org/?x=шеллы') + + // @ts-expect-error + expect(mockCypress.ensure.commandCanCommunicateWithAUT).toHaveBeenCalledOnce() + + expect(getUrlFromAutomation).not.toHaveBeenCalled() + + expect(mockCy.getRemoteLocation).toHaveBeenCalledWith('href') + }) + }) + }) + + describe('hash', () => { + describe('chromium/firefox', () => { + it('returns the hash of the url from the automation client', () => { + // @ts-expect-error + getUrlFromAutomation.mockReturnValue(() => { + return { + hash: 'foobar', + } + }) + + const hash = hashQueryCommand.call(mockContext, mockCypress, mockCy, {})() + + expect(hash).toBe('foobar') + + expect(getUrlFromAutomation).toHaveBeenCalledOnce() + + expect(mockCy.getRemoteLocation).not.toHaveBeenCalled() + }) + }) + + describe('webkit', () => { + beforeEach(() => { + mockCypress.isBrowser.mockImplementation((browserName) => { + if (browserName === 'webkit') { + return true + } + + return false + }) + }) + + it('does not use the automation client if the browser is webkit', () => { + mockCy.getRemoteLocation.mockImplementation(() => { + return 'foobar' + }) + + const hash = hashQueryCommand.call(mockContext, mockCypress, mockCy, { timeout: 10000 })() + + expect(hash).toBe('foobar') + + // @ts-expect-error + expect(mockCypress.ensure.commandCanCommunicateWithAUT).toHaveBeenCalledOnce() + + // @ts-expect-error + expect(mockContext.set).toHaveBeenCalledWith('timeout', 10000) + + expect(getUrlFromAutomation).not.toHaveBeenCalled() + + expect(mockCy.getRemoteLocation).toHaveBeenCalledWith('hash') + }) + }) + }) + + describe('location', () => { + describe('chromium/firefox', () => { + it('returns the location of the url from the automation client', () => { + // @ts-expect-error + getUrlFromAutomation.mockReturnValue(() => { + return { + protocol: 'https:', + host: 'www.example.com', + hostname: 'www.example.com', + hash: '#foobar', + search: '', + pathname: '/', + port: '', + origin: 'https://www.example.com', + href: 'https://www.example.com/#foobar', + searchParams: expect.any(Object), + } + }) + + const urlObj = locationQueryCommand.call(mockContext, mockCypress, mockCy, undefined, {})() + + expect(urlObj).toEqual({ + protocol: 'https:', + host: 'www.example.com', + hostname: 'www.example.com', + hash: '#foobar', + search: '', + pathname: '/', + port: '', + origin: 'https://www.example.com', + href: 'https://www.example.com/#foobar', + searchParams: expect.any(Object), + }) + + expect(getUrlFromAutomation).toHaveBeenCalledOnce() + + expect(mockCypress.log).toHaveBeenCalledWith({ + message: '', + hidden: false, + timeout: undefined, + }) + + expect(mockCy.getRemoteLocation).not.toHaveBeenCalled() + }) + + it('works with a string key', () => { + // @ts-expect-error + getUrlFromAutomation.mockReturnValue(() => { + return { + protocol: 'https:', + host: 'www.example.com', + hostname: 'www.example.com', + hash: '#foobar', + search: '', + pathname: '/', + port: '', + origin: 'https://www.example.com', + href: 'https://www.example.com/#foobar', + searchParams: expect.any(Object), + } + }) + + const hash = locationQueryCommand.call(mockContext, mockCypress, mockCy, 'hash', {})() + + expect(hash).toEqual('#foobar') + + expect(getUrlFromAutomation).toHaveBeenCalledOnce() + + expect(mockCypress.log).toHaveBeenCalledWith({ + message: 'hash', + hidden: false, + timeout: undefined, + }) + + expect(mockCy.getRemoteLocation).not.toHaveBeenCalled() + }) + + it('returns null if the location is empty', () => { + // @ts-expect-error + getUrlFromAutomation.mockReturnValue(() => { + return '' + }) + + const urlObj = locationQueryCommand.call(mockContext, mockCypress, mockCy, undefined, {})() + + expect(urlObj).toEqual(null) + + expect(getUrlFromAutomation).toHaveBeenCalledOnce() + + expect(mockCypress.log).toHaveBeenCalledWith({ + message: '', + hidden: false, + timeout: undefined, + }) + + expect(mockCy.getRemoteLocation).not.toHaveBeenCalled() + }) + + it('throws if the string key is invalid', () => { + // @ts-expect-error + getUrlFromAutomation.mockReturnValue(() => { + return { + protocol: 'https:', + host: 'www.example.com', + hostname: 'www.example.com', + hash: '#foobar', + search: '', + pathname: '/', + port: '', + origin: 'https://www.example.com', + href: 'https://www.example.com/#foobar', + searchParams: expect.any(Object), + } + }) + + expect(() => { + locationQueryCommand.call(mockContext, mockCypress, mockCy, 'doesnotexist', {})() + }).toThrow('Location object does not have key: `doesnotexist`') + }) + }) + + describe('webkit', () => { + beforeEach(() => { + mockCypress.isBrowser.mockImplementation((browserName) => { + if (browserName === 'webkit') { + return true + } + + return false + }) + }) + + it('does not use the automation client if the browser is webkit', () => { + mockCy.getRemoteLocation.mockImplementation(() => { + // NOTE: this is the legacy API of remote location, which is fairly close to that of the automation client + return { + auth: '', + authObj: '', + protocol: 'https:', + host: 'www.example.com', + hostname: 'www.example.com', + hash: '#foobar', + search: '', + pathname: '/', + port: '', + origin: 'https://www.example.com', + href: 'https://www.example.com/#foobar', + superDomainOrigin: 'example.com', + superDomain: 'example.com', + } + }) + + const urlLegacyObj = locationQueryCommand.call(mockContext, mockCypress, mockCy, undefined, { timeout: 10000 })() + + expect(urlLegacyObj).toEqual({ + auth: '', + authObj: '', + protocol: 'https:', + host: 'www.example.com', + hostname: 'www.example.com', + hash: '#foobar', + search: '', + pathname: '/', + port: '', + origin: 'https://www.example.com', + href: 'https://www.example.com/#foobar', + superDomainOrigin: 'example.com', + superDomain: 'example.com', + }) + + // @ts-expect-error + expect(mockCypress.ensure.commandCanCommunicateWithAUT).toHaveBeenCalledOnce() + + expect(mockContext.set).toHaveBeenCalledWith('timeout', 10000) + + expect(mockCypress.log).toHaveBeenCalledWith({ + message: '', + hidden: false, + timeout: 10000, + }) + + expect(getUrlFromAutomation).not.toHaveBeenCalled() + + expect(mockCy.getRemoteLocation).toHaveBeenCalledWith() + }) + }) + }) +}) diff --git a/packages/driver/test/unit/cy/commands/navigation.spec.ts b/packages/driver/test/unit/cy/commands/navigation.spec.ts new file mode 100644 index 000000000000..34944fe30230 --- /dev/null +++ b/packages/driver/test/unit/cy/commands/navigation.spec.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment jsdom + */ +import { vi, describe, it, expect, beforeEach, MockedObject } from 'vitest' +import { go, reload } from '../../../../src/cy/commands/navigation' +import $utils from '../../../../src/cypress/utils' +import type{ $Cy } from '../../../../src/cypress/cy' + +vi.mock('../../../../src/cypress/utils', async () => { + const original = await vi.importActual('../../../../src/cypress/utils') + + return { + default: { + // @ts-expect-error + ...original.default, + locReload: vi.fn(), + }, + } +}) + +describe('cy/commands/navigation', () => { + let mockCypress: MockedObject + let mockCy: MockedObject<$Cy> + let mockContext: MockedObject + let mockState: MockedObject + + beforeEach(() => { + mockCypress = { + log: vi.fn(), + automation: vi.fn(), + isBrowser: vi.fn(), + ensure: { + // @ts-expect-error + commandCanCommunicateWithAUT: vi.fn(), + }, + // @ts-expect-error + config: vi.fn(), + } + + mockCy = { + clearTimeout: vi.fn(), + // @ts-expect-error + once: vi.fn(), + // @ts-expect-error + removeListener: vi.fn(), + } + + mockState = vi.fn() + + mockContext = { + set: vi.fn(), + } + + mockCypress.config.mockImplementation((key) => { + //@ts-expect-error + if (key === 'pageLoadTimeout') { + return 10000 + } + }) + + //@ts-expect-error + $utils.locReload.mockReset() + }) + + describe('reload', () => { + describe('chromium/firefox', () => { + it('sends the reload:aut:frame event to the backend via the automation client', () => { + reload.call(mockContext, mockCypress, mockCy, mockState, mockCypress.config, [true]) + + expect(mockCypress.automation).toHaveBeenCalledWith('reload:aut:frame', { + forceReload: true, + }) + + expect(mockCypress.log).toHaveBeenCalledWith({ + hidden: false, + timeout: 10000, + }) + + expect($utils.locReload).not.toHaveBeenCalled() + }) + + describe('webkit', () => { + beforeEach(() => { + mockCypress.isBrowser.mockImplementation((browserName) => { + if (browserName === 'webkit') { + return true + } + + return false + }) + }) + + it('does not use the automation client if the browser is webkit', () => { + let mockWindow = {} + + mockState.mockImplementation((key) => { + if (key === 'window') { + return mockWindow + } + }) + + reload.call(mockContext, mockCypress, mockCy, mockState, mockCypress.config, [true]) + + expect(mockCypress.log).toHaveBeenCalledWith({ + hidden: false, + timeout: 10000, + }) + + expect(mockCypress.automation).not.toHaveBeenCalled() + + expect(mockCypress.log).toHaveBeenCalledWith({ + hidden: false, + timeout: 10000, + }) + + expect($utils.locReload).toHaveBeenCalledWith(true, mockWindow) + }) + }) + }) + }) + + describe('go', () => { + let mockWindow + + beforeEach(() => { + mockWindow = { + history: { + go: vi.fn(), + }, + } + + mockState.mockImplementation((key) => { + if (key === 'window') { + return mockWindow + } + }) + }) + + describe('chromium/firefox', () => { + it('sends the navigate:aut:history event to the backend via the automation client', () => { + go.call(mockContext, mockCypress, mockCy, mockState, mockCypress.config, -1, {}) + + expect(mockCypress.automation).toHaveBeenCalledWith('navigate:aut:history', { + historyNumber: -1, + }) + + expect(mockCypress.log).toHaveBeenCalledWith({ + hidden: false, + timeout: 10000, + }) + + expect(mockWindow.history.go).not.toHaveBeenCalled() + }) + + describe('webkit', () => { + beforeEach(() => { + mockCypress.isBrowser.mockImplementation((browserName) => { + if (browserName === 'webkit') { + return true + } + + return false + }) + }) + + it('does not use the automation client if the browser is webkit', () => { + go.call(mockContext, mockCypress, mockCy, mockState, mockCypress.config, -1, {}) + + expect(mockCypress.log).toHaveBeenCalledWith({ + hidden: false, + timeout: 10000, + }) + + expect(mockCypress.automation).not.toHaveBeenCalled() + + expect(mockWindow.history.go).toHaveBeenCalledWith(-1) + }) + }) + }) + }) +}) diff --git a/packages/driver/test/unit/cy/commands/window.spec.ts b/packages/driver/test/unit/cy/commands/window.spec.ts new file mode 100644 index 000000000000..1fbc25367b86 --- /dev/null +++ b/packages/driver/test/unit/cy/commands/window.spec.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ +import { vi, describe, it, expect, beforeEach, MockedObject } from 'vitest' +import { getTitleQueryCommand } from '../../../../src/cy/commands/window' +import { getTitleFromAutomation } from '../../../../src/cy/commands/helpers/window' +import type { StateFunc } from '../../../../src/cypress/state' +import type { $Cy } from '../../../../src/cypress/cy' + +vi.mock('../../../../src/cy/commands/helpers/window', async () => { + return { + getTitleFromAutomation: vi.fn(), + } +}) + +describe('cy/commands/window', () => { + let mockCypress: MockedObject + let mockState: MockedObject + let mockContext: MockedObject + let mockCy: MockedObject<$Cy> + + beforeEach(() => { + mockCypress = { + log: vi.fn(), + automation: vi.fn(), + isBrowser: vi.fn(), + ensure: { + // @ts-expect-error + commandCanCommunicateWithAUT: vi.fn(), + }, + // @ts-expect-error + config: vi.fn(), + } + + // @ts-expect-error + mockCy = {} + + // @ts-expect-error + mockState = vi.fn() + + mockContext = { + set: vi.fn(), + } + + //@ts-expect-error + getTitleFromAutomation.mockReset() + }) + + describe('title', () => { + describe('chromium/firefox', () => { + it('returns the title from the automation client', () => { + // @ts-expect-error + getTitleFromAutomation.mockReturnValue(() => 'This is the frame title') + + const title = getTitleQueryCommand.call(mockContext, mockCypress, mockCy, mockState, {})() + + expect(title).toBe('This is the frame title') + + expect(getTitleFromAutomation).toHaveBeenCalledOnce() + + expect(mockState).not.toHaveBeenCalled() + }) + }) + + describe('webkit', () => { + beforeEach(() => { + mockCypress.isBrowser.mockImplementation((browserName) => { + if (browserName === 'webkit') { + return true + } + + return false + }) + }) + + it('does not use the automation client if the browser is webkit', () => { + // @ts-expect-error + mockState.mockImplementation((key) => { + if (key === 'document') { + return { title: 'This is the frame title' } + } + }) + + const title = getTitleQueryCommand.call(mockContext, mockCypress, mockCy, mockState, { + timeout: 10000, + })() + + expect(title).toBe('This is the frame title') + + // @ts-expect-error + expect(mockCypress.ensure.commandCanCommunicateWithAUT).toHaveBeenCalledOnce() + + expect(mockContext.set).toHaveBeenCalledWith('timeout', 10000) + + expect(getTitleFromAutomation).not.toHaveBeenCalled() + + expect(mockState).toHaveBeenCalledWith('document') + }) + }) + }) +}) diff --git a/packages/server/lib/automation/commands/get_frame_title.ts b/packages/server/lib/automation/commands/get_frame_title.ts new file mode 100644 index 000000000000..d71160770df6 --- /dev/null +++ b/packages/server/lib/automation/commands/get_frame_title.ts @@ -0,0 +1,21 @@ +import { evaluateInFrameContext } from '../helpers/evaluate_in_frame_context' +import type { Protocol } from 'devtools-protocol' +import type { SendDebuggerCommand } from '../../browsers/cdp_automation' +import type { Client as WebDriverClient } from 'webdriver' + +const expressionToEvaluate = `window.document.title` + +export async function cdpGetFrameTitle (send: SendDebuggerCommand, contexts: Map, frame: Protocol.Page.Frame): Promise { + return (await evaluateInFrameContext(expressionToEvaluate, send, contexts, frame!))?.result?.value +} + +export async function bidiGetFrameTitle (webDriverClient: WebDriverClient, autContextId: string): Promise { + return (await webDriverClient.scriptEvaluate({ + expression: expressionToEvaluate, + target: { + context: autContextId, + }, + awaitPromise: false, + // @ts-expect-error - result is not typed + }))?.result?.value +} diff --git a/packages/server/lib/automation/commands/get_url.ts b/packages/server/lib/automation/commands/get_url.ts new file mode 100644 index 000000000000..83273e20cd6e --- /dev/null +++ b/packages/server/lib/automation/commands/get_url.ts @@ -0,0 +1,16 @@ +import { evaluateInFrameContext } from '../helpers/evaluate_in_frame_context' +import type { Protocol } from 'devtools-protocol' +import type { Client as WebDriverClient } from 'webdriver' +import type { SendDebuggerCommand } from '../../browsers/cdp_automation' + +export async function cdpGetUrl (send: SendDebuggerCommand, contexts: Map, frame: Protocol.Page.Frame): Promise { + return (await evaluateInFrameContext(`window.location.href`, send, contexts, frame!))?.result?.value +} + +export async function bidiGetUrl (webDriverClient: WebDriverClient, autContextId: string): Promise { + const { contexts: autContext } = await webDriverClient.browsingContextGetTree({ + root: autContextId, + }) + + return autContext ? autContext[0].url : '' +} diff --git a/packages/server/lib/automation/commands/key_press.ts b/packages/server/lib/automation/commands/key_press.ts index c54b944499f8..66a2511b7834 100644 --- a/packages/server/lib/automation/commands/key_press.ts +++ b/packages/server/lib/automation/commands/key_press.ts @@ -1,10 +1,11 @@ -import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping' import type { Protocol } from 'devtools-protocol' import type { KeyPressParams, KeyPressSupportedKeys } from '@packages/types' import type { SendDebuggerCommand } from '../../browsers/cdp_automation' import type { Client } from 'webdriver' import Debug from 'debug' -import { isEqual, isError } from 'lodash' +import { isEqual } from 'lodash' +import { evaluateInFrameContext } from '../helpers/evaluate_in_frame_context' +import { AUT_FRAME_NAME_IDENTIFIER } from '../helpers/aut_identifier' const debug = Debug('cypress:server:automation:command:keypress') @@ -30,28 +31,6 @@ export const CDP_KEYCODE: KeyCodeLookup = { 'Tab': 'U+000009', } -async function evaluateInFrameContext (expression: string, - send: SendDebuggerCommand, - contexts: Map, - frame: Protocol.Page.Frame): Promise { - for (const [contextId, context] of contexts.entries()) { - if (context.auxData?.frameId === frame.id) { - try { - return await send('Runtime.evaluate', { - expression, - contextId, - }) - } catch (e) { - if (isError(e) && (e as Error).message.includes('Cannot find context with specified id')) { - debug('found invalid context %d, removing', contextId) - contexts.delete(contextId) - } - } - } - } - throw new Error('Unable to find valid context for frame') -} - export async function cdpKeyPress ( { key }: KeyPressParams, send: SendDebuggerCommand, contexts: Map, @@ -65,7 +44,7 @@ export async function cdpKeyPress ( const keyIdentifier = CDP_KEYCODE[key] const autFrame = frameTree.childFrames?.find(({ frame }) => { - return frame.name?.includes('Your project') + return frame.name?.includes(AUT_FRAME_NAME_IDENTIFIER) }) if (!autFrame) { diff --git a/packages/server/lib/automation/commands/navigate_history.ts b/packages/server/lib/automation/commands/navigate_history.ts new file mode 100644 index 000000000000..b1765e8d9e73 --- /dev/null +++ b/packages/server/lib/automation/commands/navigate_history.ts @@ -0,0 +1,20 @@ +import { evaluateInFrameContext } from '../helpers/evaluate_in_frame_context' +import type { Protocol } from 'devtools-protocol' +import type { SendDebuggerCommand } from '../../browsers/cdp_automation' +import type { Client as WebDriverClient } from 'webdriver' + +const expressionToEvaluate = (historyNumber: number) => `window.history.go(${historyNumber})` + +export async function cdpNavigateHistory (send: SendDebuggerCommand, contexts: Map, frame: Protocol.Page.Frame, historyNumber: number): Promise { + await evaluateInFrameContext(expressionToEvaluate(historyNumber), send, contexts, frame) +} + +export async function bidiNavigateHistory (webDriverClient: WebDriverClient, autContextId: string, historyNumber: number): Promise { + await webDriverClient.scriptEvaluate({ + expression: expressionToEvaluate(historyNumber), + target: { + context: autContextId, + }, + awaitPromise: false, + }) +} diff --git a/packages/server/lib/automation/commands/reload_frame.ts b/packages/server/lib/automation/commands/reload_frame.ts new file mode 100644 index 000000000000..68b6f315be4a --- /dev/null +++ b/packages/server/lib/automation/commands/reload_frame.ts @@ -0,0 +1,20 @@ +import { evaluateInFrameContext } from '../helpers/evaluate_in_frame_context' +import type { Protocol } from 'devtools-protocol' +import type { SendDebuggerCommand } from '../../browsers/cdp_automation' +import type { Client as WebDriverClient } from 'webdriver' + +const expressionToEvaluate = (forceReload = false) => `window.location.reload(${forceReload})` + +export async function cdpReloadFrame (send: SendDebuggerCommand, contexts: Map, frame: Protocol.Page.Frame, forceReload: boolean): Promise { + await evaluateInFrameContext(expressionToEvaluate(forceReload), send, contexts, frame) +} + +export async function bidiReloadFrame (webDriverClient: WebDriverClient, autContextId: string, forceReload: boolean): Promise { + await webDriverClient.scriptEvaluate({ + expression: expressionToEvaluate(forceReload), + target: { + context: autContextId, + }, + awaitPromise: false, + }) +} diff --git a/packages/server/lib/automation/helpers/aut_identifier.ts b/packages/server/lib/automation/helpers/aut_identifier.ts new file mode 100644 index 000000000000..805d6ba1af57 --- /dev/null +++ b/packages/server/lib/automation/helpers/aut_identifier.ts @@ -0,0 +1 @@ +export const AUT_FRAME_NAME_IDENTIFIER = 'Your project:' diff --git a/packages/server/lib/automation/helpers/evaluate_in_frame_context.ts b/packages/server/lib/automation/helpers/evaluate_in_frame_context.ts new file mode 100644 index 000000000000..bb915b13a420 --- /dev/null +++ b/packages/server/lib/automation/helpers/evaluate_in_frame_context.ts @@ -0,0 +1,29 @@ +import type { Protocol } from 'devtools-protocol' +import type { SendDebuggerCommand } from '../../browsers/cdp_automation' +import Debug from 'debug' +import { isError } from 'lodash' +import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping' + +const debug = Debug('cypress:server:automation:helpers:evaluate_in_frame_context') + +export async function evaluateInFrameContext (expression: string, + send: SendDebuggerCommand, + contexts: Map, + frame: Protocol.Page.Frame): Promise { + for (const [contextId, context] of contexts.entries()) { + if (context.auxData?.frameId === frame.id) { + try { + return await send('Runtime.evaluate', { + expression, + contextId, + }) + } catch (e) { + if (isError(e) && (e as Error).message.includes('Cannot find context with specified id')) { + debug('found invalid context %d, removing', contextId) + contexts.delete(contextId) + } + } + } + } + throw new Error('Unable to find valid context for frame') +} diff --git a/packages/server/lib/browsers/bidi_automation.ts b/packages/server/lib/browsers/bidi_automation.ts index 1266d45a71b2..0cfd72ac9fea 100644 --- a/packages/server/lib/browsers/bidi_automation.ts +++ b/packages/server/lib/browsers/bidi_automation.ts @@ -21,6 +21,10 @@ import type { NetworkSameSite, } from 'webdriver/build/bidi/localTypes' import type { CyCookie } from './webkit-automation' +import { bidiGetUrl } from '../automation/commands/get_url' +import { bidiReloadFrame } from '../automation/commands/reload_frame' +import { bidiNavigateHistory } from '../automation/commands/navigate_history' +import { bidiGetFrameTitle } from '../automation/commands/get_frame_title' const BIDI_DEBUG_NAMESPACE = 'cypress:server:browsers:bidi_automation' const BIDI_COOKIE_DEBUG_NAMESPACE = `${BIDI_DEBUG_NAMESPACE}:cookies` @@ -659,6 +663,43 @@ export class BidiAutomation { } return + case 'get:aut:url': + { + if (this.autContextId) { + return bidiGetUrl(this.webDriverClient, this.autContextId) + } + + throw new Error('Cannot get AUT url: no AUT context initialized') + } + + case 'reload:aut:frame': + { + if (this.autContextId) { + await bidiReloadFrame(this.webDriverClient, this.autContextId, data.forceReload) + + return + } + + throw new Error('Cannot reload AUT frame: no AUT context initialized') + } + case 'navigate:aut:history': + { + if (this.autContextId) { + await bidiNavigateHistory(this.webDriverClient, this.autContextId, data.historyNumber) + + return + } + + throw new Error('Cannot navigate AUT frame history: no AUT context initialized') + } + case 'get:aut:title': + { + if (this.autContextId) { + return bidiGetFrameTitle(this.webDriverClient, this.autContextId) + } + + throw new Error('Cannot get AUT title no AUT context initialized') + } default: debug('BiDi automation not implemented for message: %s', message) throw new AutomationNotImplemented(message, 'BiDiAutomation') diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index 1ef28e4a7325..4770d61d233c 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -14,7 +14,12 @@ import type { CDPClient, ProtocolManagerShape, WriteVideoFrame, AutomationMiddle import type { Automation } from '../automation' import { cookieMatches, CyCookie, CyCookieFilter } from '../automation/util' import { DEFAULT_NETWORK_ENABLE_OPTIONS, CriClient } from './cri-client' +import { AUT_FRAME_NAME_IDENTIFIER } from '../automation/helpers/aut_identifier' import { cdpKeyPress } from '../automation/commands/key_press' +import { cdpGetUrl } from '../automation/commands/get_url' +import { cdpReloadFrame } from '../automation/commands/reload_frame' +import { cdpNavigateHistory } from '../automation/commands/navigate_history' +import { cdpGetFrameTitle } from '../automation/commands/get_frame_title' export type CdpCommand = keyof ProtocolMapping.Commands @@ -454,7 +459,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { } const frame = _.find(this.frameTree?.childFrames || [], ({ frame }) => { - return frame?.name?.startsWith('Your project:') + return frame?.name?.startsWith(AUT_FRAME_NAME_IDENTIFIER) }) as HasFrame | undefined if (frame) { @@ -464,6 +469,40 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { return false } + private _getAutFrame = async () => { + try { + if (this.gettingFrameTree) { + debugVerbose('awaiting frame tree') + + await this.gettingFrameTree + } + + const frameTree = (await this.send('Page.getFrameTree')).frameTree + + let frame = _.find(frameTree?.childFrames || [], (item: HasFrame) => { + return item.frame?.name?.startsWith(AUT_FRAME_NAME_IDENTIFIER) + }) as HasFrame | undefined + + // If we are in E2E Cypress in Cypress testing, we need to get the frame from the child frames of the AUT frame. Else we are reloading what would be the "top" frame under test (with the AUT and reporter_) + if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF && frame) { + // @ts-expect-error + frame = _.find(frame?.childFrames || [], (item: HasFrame) => { + return item.frame?.name?.startsWith(AUT_FRAME_NAME_IDENTIFIER) + }) as HasFrame | undefined + } + + if (!frame) { + throw new Error('Could not find AUT frame') + } + + return frame.frame + } catch (err) { + debugVerbose('failed to get aut frame:', err.stack) + + throw new Error('Could not find AUT frame') + } + } + _handlePausedRequests = async (client: CriClient) => { // NOTE: only supported in chromium based browsers await client.send('Fetch.enable', { @@ -604,13 +643,15 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { case 'collect:garbage': return this.sendDebuggerCommandFn('HeapProfiler.collectGarbage') case 'key:press': - if (this.gettingFrameTree) { - debugVerbose('awaiting frame tree') - - await this.gettingFrameTree - } - return cdpKeyPress(data, this.sendDebuggerCommandFn, this.executionContexts, (await this.send('Page.getFrameTree')).frameTree) + case 'get:aut:url': + return cdpGetUrl(this.sendDebuggerCommandFn, this.executionContexts, await this._getAutFrame()) + case 'reload:aut:frame': + return cdpReloadFrame(this.sendDebuggerCommandFn, this.executionContexts, await this._getAutFrame(), data.forceReload) + case 'navigate:aut:history': + return cdpNavigateHistory(this.sendDebuggerCommandFn, this.executionContexts, await this._getAutFrame(), data.historyNumber) + case 'get:aut:title': + return cdpGetFrameTitle(this.sendDebuggerCommandFn, this.executionContexts, await this._getAutFrame()) default: throw new Error(`No automation handler registered for: '${message}'`) } diff --git a/packages/server/test/unit/automation/commands/key_press.spec.ts b/packages/server/test/unit/automation/commands/key_press.spec.ts index 8365aab5289a..3b71235d9bdd 100644 --- a/packages/server/test/unit/automation/commands/key_press.spec.ts +++ b/packages/server/test/unit/automation/commands/key_press.spec.ts @@ -41,7 +41,7 @@ describe('key:press automation command', () => { const autFrame = { frame: { id: autFrameId, - name: 'Your project', + name: 'Your project:', }, } diff --git a/packages/server/test/unit/browsers/bidi_automation_spec.ts b/packages/server/test/unit/browsers/bidi_automation_spec.ts index dab7d8d4eddd..2473dd59060f 100644 --- a/packages/server/test/unit/browsers/bidi_automation_spec.ts +++ b/packages/server/test/unit/browsers/bidi_automation_spec.ts @@ -11,9 +11,7 @@ import type { NetworkFetchErrorParameters, NetworkResponseCompletedParameters, N // make sure testing promises resolve before asserting on async function conditions const flushPromises = () => { return new Promise((resolve) => { - setTimeout(() => { - resolve() - }, 10) + setTimeout(resolve) }) } @@ -2167,6 +2165,133 @@ describe('lib/browsers/bidi_automation', () => { }) }) + describe('get:aut:url', () => { + it('gets the application url', async () => { + mockWebdriverClient.browsingContextGetTree = sinon.stub().resolves({ + contexts: [{ context: '123', url: 'http://localhost:3500/fixtures/dom.html' }], + }) + + //@ts-expect-error + bidiAutomationInstance.autContextId = '123' + + const url = await bidiAutomationInstance.automationMiddleware.onRequest('get:aut:url', undefined) + + expect(mockWebdriverClient.browsingContextGetTree).to.have.been.calledWith({ + root: '123', + }) + + expect(url).to.equal('http://localhost:3500/fixtures/dom.html') + }) + + it('fails gracefully if no AUT context is initialized', async () => { + //@ts-expect-error + bidiAutomationInstance.autContextId = undefined + + expect(bidiAutomationInstance.automationMiddleware.onRequest('get:aut:url', undefined)).to.be.rejectedWith('Cannot get AUT url: no AUT context initialized') + }) + }) + + describe('reload:aut:frame', () => { + it('uses scriptEvaluate to reload the AUT window', async () => { + mockWebdriverClient.scriptEvaluate = sinon.stub().resolves() + + //@ts-expect-error + bidiAutomationInstance.autContextId = '123' + + await bidiAutomationInstance.automationMiddleware.onRequest('reload:aut:frame', { forceReload: false }) + + expect(mockWebdriverClient.scriptEvaluate).to.have.been.calledWith({ + expression: `window.location.reload(false)`, + target: { + context: '123', + }, + awaitPromise: false, + }) + }) + + it('uses scriptEvaluate to reload the AUT window with the force option', async () => { + mockWebdriverClient.scriptEvaluate = sinon.stub().resolves() + + //@ts-expect-error + bidiAutomationInstance.autContextId = '123' + + await bidiAutomationInstance.automationMiddleware.onRequest('reload:aut:frame', { forceReload: true }) + + expect(mockWebdriverClient.scriptEvaluate).to.have.been.calledWith({ + expression: `window.location.reload(true)`, + target: { + context: '123', + }, + awaitPromise: false, + }) + }) + + it('fails gracefully if no AUT context is initialized', async () => { + //@ts-expect-error + bidiAutomationInstance.autContextId = undefined + + expect(bidiAutomationInstance.automationMiddleware.onRequest('reload:aut:frame', undefined)).to.be.rejectedWith('Cannot reload AUT frame: no AUT context initialized') + }) + }) + + describe('navigate:aut:history', () => { + it('uses scriptEvaluate to navigate the AUT window history', async () => { + mockWebdriverClient.scriptEvaluate = sinon.stub().resolves() + + //@ts-expect-error + bidiAutomationInstance.autContextId = '123' + + await bidiAutomationInstance.automationMiddleware.onRequest('navigate:aut:history', { historyNumber: -1 }) + + expect(mockWebdriverClient.scriptEvaluate).to.have.been.calledWith({ + expression: `window.history.go(-1)`, + target: { + context: '123', + }, + awaitPromise: false, + }) + }) + + it('fails gracefully if no AUT context is initialized', async () => { + //@ts-expect-error + bidiAutomationInstance.autContextId = undefined + + expect(bidiAutomationInstance.automationMiddleware.onRequest('navigate:aut:history', undefined)).to.be.rejectedWith('Cannot navigate AUT frame history: no AUT context initialized') + }) + }) + + describe('get:aut:title', () => { + it('uses scriptEvaluate to get the AUT title', async () => { + mockWebdriverClient.scriptEvaluate = sinon.stub().resolves({ + result: { + value: 'test title', + }, + }) + + //@ts-expect-error + bidiAutomationInstance.autContextId = '123' + + const title = await bidiAutomationInstance.automationMiddleware.onRequest('get:aut:title', undefined) + + expect(mockWebdriverClient.scriptEvaluate).to.have.been.calledWith({ + expression: `window.document.title`, + target: { + context: '123', + }, + awaitPromise: false, + }) + + expect(title).to.equal('test title') + }) + + it('fails gracefully if no AUT context is initialized', async () => { + //@ts-expect-error + bidiAutomationInstance.autContextId = undefined + + expect(bidiAutomationInstance.automationMiddleware.onRequest('get:aut:title', undefined)).to.be.rejectedWith('Cannot get AUT title no AUT context initialized') + }) + }) + it('throws an error if an event passed in does not exist', () => { // @ts-expect-error expect(bidiAutomationInstance.automationMiddleware.onRequest('foo:bar:baz', {})).to.be.rejectedWith('Automation command \'foo:bar:baz\' not implemented by BiDiAutomation') diff --git a/packages/server/test/unit/browsers/cdp_automation_spec.ts b/packages/server/test/unit/browsers/cdp_automation_spec.ts index 5ef92013aee5..9f2db9296289 100644 --- a/packages/server/test/unit/browsers/cdp_automation_spec.ts +++ b/packages/server/test/unit/browsers/cdp_automation_spec.ts @@ -603,5 +603,208 @@ context('lib/browsers/cdp_automation', () => { return this.onRequest('collect:garbage').then((resp) => expect(resp).to.be.undefined) }) }) + + describe('get:aut:url', function () { + it('gets the application url via CDP', async function () { + this.sendDebuggerCommand.withArgs('Page.getFrameTree').resolves({ + frameTree: + { + childFrames: [ + { + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + }, + }) + + this.sendDebuggerCommand.withArgs('Runtime.evaluate').resolves({ + result: { + type: 'string', + value: 'http://localhost:3500/fixtures/dom.html', + }, + }) + + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + const resp = await this.onRequest('get:aut:url') + + expect(resp).to.equal('http://localhost:3500/fixtures/dom.html') + + expect(this.sendDebuggerCommand).to.be.calledWith('Runtime.evaluate', { + expression: 'window.location.href', + contextId: 123, + }) + }) + + it('fails silently if the frame cannot be found', async function () { + expect(this.onRequest('get:aut:url')).to.be.rejectedWith('Could not find AUT frame') + }) + }) + + describe('reload:aut:frame', function () { + it('reloads the application', async function () { + this.sendDebuggerCommand.withArgs('Page.getFrameTree').resolves({ + frameTree: + { + childFrames: [ + { + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + }, + }) + + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + const resp = await this.onRequest('reload:aut:frame', { forceReload: false }) + + expect(resp).to.be.undefined + + expect(this.sendDebuggerCommand).to.be.calledWith('Runtime.evaluate', { + expression: 'window.location.reload(false)', + contextId: 123, + }) + }) + + it('reloads the application via the force option', async function () { + this.sendDebuggerCommand.withArgs('Page.getFrameTree').resolves({ + frameTree: + { + childFrames: [ + { + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + }, + }) + + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + const resp = await this.onRequest('reload:aut:frame', { forceReload: true }) + + expect(resp).to.be.undefined + + expect(this.sendDebuggerCommand).to.be.calledWith('Runtime.evaluate', { + expression: 'window.location.reload(true)', + contextId: 123, + }) + }) + + it('fails if the frame cannot be found', async function () { + expect(this.onRequest('reload:aut:frame', { forceReload: false })).to.be.rejectedWith('Could not find AUT frame') + }) + }) + + describe('navigate:aut:history', function () { + it('navigates the AUT history', async function () { + this.sendDebuggerCommand.withArgs('Page.getFrameTree').resolves({ + frameTree: + { + childFrames: [ + { + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + }, + }) + + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + const resp = await this.onRequest('navigate:aut:history', { historyNumber: 1 }) + + expect(resp).to.be.undefined + + expect(this.sendDebuggerCommand).to.be.calledWith('Runtime.evaluate', { + expression: 'window.history.go(1)', + contextId: 123, + }) + }) + + it('fails if the frame cannot be found', async function () { + expect(this.onRequest('navigate:aut:history', { historyNumber: 1 })).to.be.rejectedWith('Could not find AUT frame') + }) + }) + + describe('get:aut:title', function () { + it('is able to get the AUT title', async function () { + this.sendDebuggerCommand.withArgs('Page.getFrameTree').resolves({ + frameTree: + { + childFrames: [ + { + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + }, + }) + + this.sendDebuggerCommand.withArgs('Runtime.evaluate').resolves({ + result: { + type: 'string', + value: 'mock title', + }, + }) + + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + const resp = await this.onRequest('get:aut:title') + + expect(resp).to.equal('mock title') + + expect(this.sendDebuggerCommand).to.be.calledWith('Runtime.evaluate', { + expression: 'window.document.title', + contextId: 123, + }) + }) + + it('fails if the frame cannot be found', async function () { + expect(this.onRequest('get:aut:title')).to.be.rejectedWith('Could not find AUT frame') + }) + }) }) }) diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index e60114f0bbc5..3bcdbb39d0db 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -91,6 +91,10 @@ export interface AutomationCommands { 'remote:debugger:protocol': CommandSignature 'response:received': CommandSignature 'key:press': CommandSignature + 'get:aut:url': CommandSignature + 'reload:aut:frame': CommandSignature<{ forceReload: boolean }, void> + 'navigate:aut:history': CommandSignature<{ historyNumber: number }, void> + 'get:aut:title': CommandSignature } export type OnRequestEvent = (message: T, data: AutomationCommands[T]['dataType']) => Promise diff --git a/system-tests/__snapshots__/web_security_spec.js b/system-tests/__snapshots__/web_security_spec.js index 16be6dac9785..f6af2f55d9bd 100644 --- a/system-tests/__snapshots__/web_security_spec.js +++ b/system-tests/__snapshots__/web_security_spec.js @@ -30,7 +30,7 @@ exports['e2e web security / when enabled / fails'] = ` 1) web security fails when clicking to another origin: - CypressError: The command was expected to run against origin \`http://localhost:4466\` but the application is at origin \`https://www.foo.com:44665\`. + CypressError: Timed out retrying after 4000ms: The command was expected to run against origin \`http://localhost:4466\` but the application is at origin \`https://www.foo.com:44665\`. This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly. @@ -45,7 +45,7 @@ https://on.cypress.io/cy-visit-succeeded-but-commands-fail 2) web security fails when submitted a form and being redirected to another origin: - CypressError: The command was expected to run against origin \`http://localhost:4466\` but the application is at origin \`https://www.foo.com:44665\`. + CypressError: Timed out retrying after 4000ms: The command was expected to run against origin \`http://localhost:4466\` but the application is at origin \`https://www.foo.com:44665\`. This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly. @@ -60,7 +60,7 @@ https://on.cypress.io/cy-visit-succeeded-but-commands-fail 3) web security fails when using a javascript redirect to another origin: - CypressError: The command was expected to run against origin \`http://localhost:4466\` but the application is at origin \`https://www.foo.com:44665\`. + CypressError: Timed out retrying after 4000ms: The command was expected to run against origin \`http://localhost:4466\` but the application is at origin \`https://www.foo.com:44665\`. This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly.