From ce9fdee1262a02f2a58b194727f91281ea2c8809 Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Tue, 27 May 2025 18:54:38 -0400 Subject: [PATCH 1/3] spike: cut over cy.url(), cy.hash(), cy.location(), cy.reload(), cy.go(), and cy.title() all to use the automation client to subvert the cross-origin boundary refactor backend client --- .circleci/cache-version.txt | 2 +- .../cypress/e2e/commands/location.cy.js | 37 ++-- .../cypress/e2e/commands/navigation.cy.js | 35 ++-- .../e2e/e2e/origin/commands/actions.cy.ts | 124 +++++------ .../e2e/e2e/origin/commands/location.cy.ts | 5 +- packages/driver/src/cy/commands/location.ts | 148 +++++++++++-- packages/driver/src/cy/commands/navigation.ts | 21 +- packages/driver/src/cy/commands/window.ts | 74 ++++++- .../automation/commands/get_frame_title.ts | 21 ++ .../server/lib/automation/commands/get_url.ts | 16 ++ .../lib/automation/commands/key_press.ts | 26 +-- .../automation/commands/navigate_history.ts | 20 ++ .../lib/automation/commands/reload_frame.ts | 20 ++ .../helpers/evaluate_in_frame_context.ts | 29 +++ .../server/lib/browsers/bidi_automation.ts | 41 ++++ .../server/lib/browsers/cdp_automation.ts | 62 +++++- .../unit/browsers/bidi_automation_spec.ts | 127 +++++++++++ .../test/unit/browsers/cdp_automation_spec.ts | 198 ++++++++++++++++++ packages/types/src/server.ts | 4 + .../__snapshots__/web_security_spec.js | 6 +- 20 files changed, 843 insertions(+), 173 deletions(-) create mode 100644 packages/server/lib/automation/commands/get_frame_title.ts create mode 100644 packages/server/lib/automation/commands/get_url.ts create mode 100644 packages/server/lib/automation/commands/navigate_history.ts create mode 100644 packages/server/lib/automation/commands/reload_frame.ts create mode 100644 packages/server/lib/automation/helpers/evaluate_in_frame_context.ts diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index 94c6d8a28870..1ae1311acbea 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 +5-30-2025 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 433e8826004a..3e51f78c3828 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: 1 }) }) 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: 1 }) }) it('returns the window object', () => { @@ -415,7 +403,7 @@ describe('src/cy/commands/navigation', () => { const rel = cy.stub(win, 'removeEventListener') cy.go('back').then(() => { - const unloadEvent = cy.browser.family === 'chromium' ? 'pagehide' : 'unload' + const unloadEvent = Cypress.browser.family === 'chromium' ? 'pagehide' : 'unload' expect(rel).to.be.calledWith('beforeunload') expect(rel).to.be.calledWith(unloadEvent) @@ -600,14 +588,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..801a134836f3 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 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\`})`) - 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/location.ts b/packages/driver/src/cy/commands/location.ts index d68d93eb59aa..85ddf96de8b2 100644 --- a/packages/driver/src/cy/commands/location.ts +++ b/packages/driver/src/cy/commands/location.ts @@ -2,30 +2,137 @@ import _ from 'lodash' import $errUtils from '../../cypress/error_utils' +class UrlNotYetAvailableError extends Error { + constructor () { + const message = 'URL is not yet available' + + super(message) + this.name = 'UrlNotYetAvailableError' + } +} + +function getUrlFromAutomation (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.name = err.name + mostRecentError.message = err.message + }) + .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 === '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 + } +} + export default (Commands, Cypress, cy) => { Commands.addQuery('url', function url (options: Partial = {}) { - // Make sure the url command can communicate with the AUT. - // otherwise, it yields an empty string - Cypress.ensure.commandCanCommunicateWithAUT(cy) - this.set('timeout', options.timeout) - 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 + Cypress.ensure.commandCanCommunicateWithAUT(cy) + this.set('timeout', options.timeout) + + return () => { + const href = cy.getRemoteLocation('href') + + return options.decode ? decodeURI(href) : href + } + } + + const fn = getUrlFromAutomation.bind(this)(options) + return () => { - const href = cy.getRemoteLocation('href') + const fullUrlObj = fn() - return options.decode ? decodeURI(href) : href + if (fullUrlObj) { + const href = fullUrlObj.href + + return options.decode ? decodeURI(href) : href + } } }) Commands.addQuery('hash', function url (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 hash + if (Cypress.isBrowser('webkit')) { // Make sure the hash command can communicate with the AUT. - Cypress.ensure.commandCanCommunicateWithAUT(cy) - this.set('timeout', options.timeout) + Cypress.ensure.commandCanCommunicateWithAUT(cy) + this.set('timeout', options.timeout) - Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) + Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) + + return () => cy.getRemoteLocation('hash') + } + + const fn = getUrlFromAutomation.bind(this)(options) + + return () => { + const fullUrlObj = fn() - return () => cy.getRemoteLocation('hash') + if (fullUrlObj) { + return fullUrlObj.hash + } + } }) Commands.addQuery('location', function location (key, options: Partial = {}) { @@ -34,21 +141,32 @@ export default (Commands, Cypress, cy) => { // 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. - 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, }) + // 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. + Cypress.ensure.commandCanCommunicateWithAUT(cy) + + this.set('timeout', options.timeout) + } + + const fn = Cypress.isBrowser('webkit') ? cy.getRemoteLocation() : getUrlFromAutomation.bind(this)(options) + return () => { - const location = cy.getRemoteLocation() + const location = fn() if (location === '') { // maybe the page's domain is "invisible" to us diff --git a/packages/driver/src/cy/commands/navigation.ts b/packages/driver/src/cy/commands/navigation.ts index 496d672409c0..82bfd21e147e 100644 --- a/packages/driver/src/cy/commands/navigation.ts +++ b/packages/driver/src/cy/commands/navigation.ts @@ -602,7 +602,10 @@ export default (Commands, Cypress, cy, state, config) => { cy.once('window:load', resolve) - return $utils.locReload(forceReload, state('window')) + // 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, + }) }) } @@ -616,10 +619,6 @@ export default (Commands, Cypress, cy, state, config) => { 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 }) }, @@ -632,8 +631,6 @@ export default (Commands, Cypress, cy, state, config) => { 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 }) @@ -670,15 +667,16 @@ export default (Commands, Cypress, cy, state, config) => { 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 + // 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 @@ -704,9 +702,6 @@ export default (Commands, Cypress, cy, state, config) => { cleanup() } - // Make sure the go command can communicate with the AUT. - Cypress.ensure.commandCanCommunicateWithAUT(cy) - return null }) } diff --git a/packages/driver/src/cy/commands/window.ts b/packages/driver/src/cy/commands/window.ts index a42ef1b98472..7f2e1f32b29a 100644 --- a/packages/driver/src/cy/commands/window.ts +++ b/packages/driver/src/cy/commands/window.ts @@ -27,6 +27,15 @@ const viewports = { const validOrientations = ['landscape', 'portrait'] +class TitleNotYetAvailableError extends Error { + constructor () { + const message = 'document.title is not yet available' + + super(message) + this.name = 'TitleNotYetAvailableError' + } +} + type CurrentViewport = Pick // NOTE: this is outside the function because its 'global' state to the @@ -89,18 +98,71 @@ 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) + // 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) + + return () => (state('document')?.title || '') + } + + const timeout = options.timeout ?? Cypress.config('defaultCommandTimeout') as number + + this.set('timeout', timeout) + Cypress.log({ timeout: options.timeout, hidden: options.log === false }) - return () => (state('document')?.title || '') + 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.name = err.name + mostRecentError.message = err.message + }) + .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 + } }) 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/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..fa6b7e2be4ca 100644 --- a/packages/server/lib/automation/commands/key_press.ts +++ b/packages/server/lib/automation/commands/key_press.ts @@ -1,10 +1,10 @@ -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' const debug = Debug('cypress:server:automation:command:keypress') @@ -30,28 +30,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, 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/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..7478a4707482 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -15,6 +15,10 @@ import type { Automation } from '../automation' import { cookieMatches, CyCookie, CyCookieFilter } from '../automation/util' import { DEFAULT_NETWORK_ENABLE_OPTIONS, CriClient } from './cri-client' 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 @@ -447,23 +451,35 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { // the request could come in while in the middle of getting the frame tree, // which is asynchronous, so wait for it to be fetched - if (this.gettingFrameTree) { - debugVerbose('awaiting frame tree') - - await this.gettingFrameTree - } - - const frame = _.find(this.frameTree?.childFrames || [], ({ frame }) => { - return frame?.name?.startsWith('Your project:') - }) as HasFrame | undefined + const frame = await this._getAutFrame() if (frame) { - return frame.frame.id === frameId + return frame.id === frameId } return false } + private _getAutFrame = async () => { + try { + if (this.gettingFrameTree) { + debugVerbose('awaiting frame tree') + + await this.gettingFrameTree + } + + const frame = _.find(this.frameTree?.childFrames || [], (item: HasFrame) => { + return item.frame?.name?.startsWith('Your project:') + }) as HasFrame | undefined + + return frame?.frame + } catch (err) { + debugVerbose('failed to get aut frame:', err.stack) + + return undefined + } + } + _handlePausedRequests = async (client: CriClient) => { // NOTE: only supported in chromium based browsers await client.send('Fetch.enable', { @@ -611,6 +627,32 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { } return cdpKeyPress(data, this.sendDebuggerCommandFn, this.executionContexts, (await this.send('Page.getFrameTree')).frameTree) + case 'get:aut:url': + { + const frame = await this._getAutFrame() + + return cdpGetUrl(this.sendDebuggerCommandFn, this.executionContexts, frame!) + } + case 'reload:aut:frame': + { + const frame = await this._getAutFrame() + + await cdpReloadFrame(this.sendDebuggerCommandFn, this.executionContexts, frame!, data.forceReload) + + return + } + case 'navigate:aut:history': + { + const frame = await this._getAutFrame() + + return cdpNavigateHistory(this.sendDebuggerCommandFn, this.executionContexts, frame!, data.historyNumber!) + } + case 'get:aut:title': + { + const frame = await this._getAutFrame() + + return cdpGetFrameTitle(this.sendDebuggerCommandFn, this.executionContexts, frame!) + } default: throw new Error(`No automation handler registered for: '${message}'`) } diff --git a/packages/server/test/unit/browsers/bidi_automation_spec.ts b/packages/server/test/unit/browsers/bidi_automation_spec.ts index dab7d8d4eddd..c6e6dd5a8b00 100644 --- a/packages/server/test/unit/browsers/bidi_automation_spec.ts +++ b/packages/server/test/unit/browsers/bidi_automation_spec.ts @@ -2167,6 +2167,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..3cf806489602 100644 --- a/packages/server/test/unit/browsers/cdp_automation_spec.ts +++ b/packages/server/test/unit/browsers/cdp_automation_spec.ts @@ -603,5 +603,203 @@ 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('Runtime.evaluate').resolves({ + result: { + type: 'string', + value: 'http://localhost:3500/fixtures/dom.html', + }, + }) + + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + // @ts-expect-error + cdpAutomation.frameTree = { + childFrames: [ + { + // @ts-expect-error + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + } + + 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('Unable to find valid context for frame') + }) + }) + + describe('reload:aut:frame', function () { + it('reloads the application', async function () { + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + // @ts-expect-error + cdpAutomation.frameTree = { + childFrames: [ + { + // @ts-expect-error + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + } + + 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 () { + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + // @ts-expect-error + cdpAutomation.frameTree = { + childFrames: [ + { + // @ts-expect-error + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + } + + 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('Unable to find valid context for frame') + }) + }) + + describe('navigate:aut:history', function () { + it('navigates the AUT history', async function () { + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + // @ts-expect-error + cdpAutomation.frameTree = { + childFrames: [ + { + // @ts-expect-error + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + } + + 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('Unable to find valid context for frame') + }) + }) + + describe('get:aut:title', function () { + it('is able to get the AUT title', async function () { + this.sendDebuggerCommand.withArgs('Runtime.evaluate').resolves({ + result: { + type: 'string', + value: 'mock title', + }, + }) + + // @ts-expect-error + cdpAutomation.executionContexts.set(123, { + auxData: { + frameId: '1', + }, + }) + + // @ts-expect-error + cdpAutomation.frameTree = { + childFrames: [ + { + // @ts-expect-error + frame: { + id: '1', + name: 'Your project: foobar', + url: 'http://localhost:3500/fixtures/dom.html', + }, + }, + ], + } + + 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('Unable to find valid context for 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. From e67426ac9629daad34d04ce0238006985bf7cde0 Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Mon, 9 Jun 2025 13:23:59 -0400 Subject: [PATCH 2/3] chore: add unit tests for cy url, hash, location, title, reload, and go changes to make it easier to test minor behavior changes bump cache --- .circleci/cache-version.txt | 2 +- .../cypress/e2e/commands/navigation.cy.js | 4 +- .../src/cy/commands/helpers/location.ts | 76 ++++ .../driver/src/cy/commands/helpers/window.ts | 63 +++ packages/driver/src/cy/commands/location.ts | 230 ++++------ packages/driver/src/cy/commands/navigation.ts | 407 ++++++++++-------- packages/driver/src/cy/commands/window.ts | 94 +--- .../unit/cy/commands/helpers/location.spec.ts | 278 ++++++++++++ .../unit/cy/commands/helpers/window.spec.ts | 267 ++++++++++++ .../test/unit/cy/commands/location.spec.ts | 374 ++++++++++++++++ .../test/unit/cy/commands/navigation.spec.ts | 181 ++++++++ .../test/unit/cy/commands/window.spec.ts | 101 +++++ 12 files changed, 1667 insertions(+), 410 deletions(-) create mode 100644 packages/driver/src/cy/commands/helpers/location.ts create mode 100644 packages/driver/src/cy/commands/helpers/window.ts create mode 100644 packages/driver/test/unit/cy/commands/helpers/location.spec.ts create mode 100644 packages/driver/test/unit/cy/commands/helpers/window.spec.ts create mode 100644 packages/driver/test/unit/cy/commands/location.spec.ts create mode 100644 packages/driver/test/unit/cy/commands/navigation.spec.ts create mode 100644 packages/driver/test/unit/cy/commands/window.spec.ts diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index 1ae1311acbea..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-30-2025 +6-9-2025 diff --git a/packages/driver/cypress/e2e/commands/navigation.cy.js b/packages/driver/cypress/e2e/commands/navigation.cy.js index 3e51f78c3828..4da101ba9529 100644 --- a/packages/driver/cypress/e2e/commands/navigation.cy.js +++ b/packages/driver/cypress/e2e/commands/navigation.cy.js @@ -17,7 +17,7 @@ describe('src/cy/commands/navigation', () => { cy.stub(Cypress, 'automation').withArgs('reload:aut:frame', { forceReload: false }).resolves() - cy.reload({ timeout: 1 }) + cy.reload({ timeout: 1000 }) }) it('can pass forceReload + options', () => { @@ -27,7 +27,7 @@ describe('src/cy/commands/navigation', () => { cy.stub(Cypress, 'automation').withArgs('reload:aut:frame', { forceReload: true }).resolves() - cy.reload(true, { timeout: 1 }) + cy.reload(true, { timeout: 1000 }) }) it('returns the window 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..207e46877290 --- /dev/null +++ b/packages/driver/src/cy/commands/helpers/location.ts @@ -0,0 +1,76 @@ +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.name = err.name + mostRecentError.message = err.message + }) + .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..fa23c16a2b32 --- /dev/null +++ b/packages/driver/src/cy/commands/helpers/window.ts @@ -0,0 +1,63 @@ +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.name = err.name + mostRecentError.message = err.message + }) + .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 85ddf96de8b2..43fdeb905423 100644 --- a/packages/driver/src/cy/commands/location.ts +++ b/packages/driver/src/cy/commands/location.ts @@ -1,186 +1,126 @@ import _ from 'lodash' import $errUtils from '../../cypress/error_utils' +import { getUrlFromAutomation } from './helpers/location' -class UrlNotYetAvailableError extends Error { - constructor () { - const message = 'URL is not yet available' +export function urlQueryCommand (Cypress: Cypress.Cypress, cy: Cypress.Cypress, options: Partial = {}) { + Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) - super(message) - this.name = 'UrlNotYetAvailableError' - } -} - -function getUrlFromAutomation (options: Partial = {}) { - const timeout = options.timeout ?? Cypress.config('defaultCommandTimeout') as number + // 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) - 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() + return () => { + // @ts-expect-error + const href = cy.getRemoteLocation('href') - const getUrlFromAutomation = () => { - if (automationPromise) { - return automationPromise + return options.decode ? decodeURI(href) : href } - - 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.name = err.name - mostRecentError.message = err.message - }) - .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 === 'UrlNotYetAvailableError' || err.name === 'AssertionError') { - // tslint:disable-next-line no-floating-promises - getUrlFromAutomation() - } else { - throw err - } - }) + const fn = getUrlFromAutomation.bind(this)(Cypress, options) return () => { - if (fullUrlObj) { - return fullUrlObj - } + const fullUrlObj = fn() - // tslint:disable-next-line no-floating-promises - getUrlFromAutomation() + if (fullUrlObj) { + const href = fullUrlObj.href - throw mostRecentError + return options.decode ? decodeURI(href) : href + } } } -export default (Commands, Cypress, cy) => { - Commands.addQuery('url', function url (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 - Cypress.ensure.commandCanCommunicateWithAUT(cy) - this.set('timeout', options.timeout) - - return () => { - const href = cy.getRemoteLocation('href') - - return options.decode ? decodeURI(href) : href - } - } - - const fn = getUrlFromAutomation.bind(this)(options) +export function hashQueryCommand (Cypress: Cypress.Cypress, cy: Cypress.Cypress, options: Partial = {}) { + Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) - return () => { - const fullUrlObj = fn() - - if (fullUrlObj) { - const href = fullUrlObj.href - - return options.decode ? decodeURI(href) : href - } - } - }) + // 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) - Commands.addQuery('hash', function url (options: Partial = {}) { - Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) + // @ts-expect-error + return () => cy.getRemoteLocation('hash') + } - // 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. - Cypress.ensure.commandCanCommunicateWithAUT(cy) - this.set('timeout', options.timeout) + const fn = getUrlFromAutomation.bind(this)(Cypress, options) - Cypress.log({ message: '', hidden: options.log === false, timeout: options.timeout }) + return () => { + const fullUrlObj = fn() - return () => cy.getRemoteLocation('hash') + if (fullUrlObj) { + return fullUrlObj.hash } + } +} - const fn = getUrlFromAutomation.bind(this)(options) +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 - return () => { - const fullUrlObj = fn() + // 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 + } - if (fullUrlObj) { - return fullUrlObj.hash - } - } + 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. - if (_.isObject(key)) { - options = key - } - - Cypress.log({ - message: _.isString(key) ? key : '', - hidden: options.log === false, - timeout: options.timeout, - }) + //@ts-expect-error + Cypress.ensure.commandCanCommunicateWithAUT(cy) - // 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 + this.set('timeout', options.timeout) + } - // 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. - Cypress.ensure.commandCanCommunicateWithAUT(cy) + //@ts-expect-error + const fn = Cypress.isBrowser('webkit') ? cy.getRemoteLocation : getUrlFromAutomation.bind(this)(Cypress, options) - this.set('timeout', options.timeout) + 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 } - const fn = Cypress.isBrowser('webkit') ? cy.getRemoteLocation() : getUrlFromAutomation.bind(this)(options) + 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 82bfd21e147e..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,197 +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) - - // 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() - } - - 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 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 - - // 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() - } - - 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 7f2e1f32b29a..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', @@ -27,15 +30,6 @@ const viewports = { const validOrientations = ['landscape', 'portrait'] -class TitleNotYetAvailableError extends Error { - constructor () { - const message = 'document.title is not yet available' - - super(message) - this.name = 'TitleNotYetAvailableError' - } -} - type CurrentViewport = Pick // NOTE: this is outside the function because its 'global' state to the @@ -49,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') @@ -97,68 +109,8 @@ export default (Commands, Cypress, cy, state) => { }) } - Commands.addQuery('title', function title (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 title - if (Cypress.isBrowser('webkit')) { - this.set('timeout', options.timeout) - - return () => (state('document')?.title || '') - } - - const timeout = options.timeout ?? Cypress.config('defaultCommandTimeout') as number - - this.set('timeout', timeout) - - Cypress.log({ timeout: options.timeout, hidden: options.log === false }) - - 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.name = err.name - mostRecentError.message = err.message - }) - .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 - } + Commands.addQuery('title', function (options: Partial = {}) { + return getTitleQueryCommand.call(this, Cypress, cy, state, options) }) Commands.addQuery('window', function windowFn (options: Partial = {}) { 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..ce507840ccb8 --- /dev/null +++ b/packages/driver/test/unit/cy/commands/helpers/location.spec.ts @@ -0,0 +1,278 @@ +/** + * @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() + }, 10) + }) +} + +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..903ec5a48281 --- /dev/null +++ b/packages/driver/test/unit/cy/commands/helpers/window.spec.ts @@ -0,0 +1,267 @@ +/** + * @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() + }, 10) + }) +} + +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') + }) + }) + }) +}) From 5956c4bb5f6c53f5f92bd86cc64fa9f2fc6cd77d Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Tue, 10 Jun 2025 11:48:39 -0400 Subject: [PATCH 3/3] fix issues with cy in cy tests. refactor aut discovery code as the frame tree gets stale on reload --- .../lib/automation/commands/key_press.ts | 3 +- .../lib/automation/helpers/aut_identifier.ts | 1 + .../server/lib/browsers/cdp_automation.ts | 67 ++++---- .../automation/commands/key_press.spec.ts | 2 +- .../test/unit/browsers/cdp_automation_spec.ts | 153 +++++++++--------- 5 files changed, 116 insertions(+), 110 deletions(-) create mode 100644 packages/server/lib/automation/helpers/aut_identifier.ts diff --git a/packages/server/lib/automation/commands/key_press.ts b/packages/server/lib/automation/commands/key_press.ts index fa6b7e2be4ca..66a2511b7834 100644 --- a/packages/server/lib/automation/commands/key_press.ts +++ b/packages/server/lib/automation/commands/key_press.ts @@ -5,6 +5,7 @@ import type { Client } from 'webdriver' import Debug from 'debug' 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') @@ -43,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/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/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index 7478a4707482..4770d61d233c 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -14,6 +14,7 @@ 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' @@ -451,10 +452,18 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { // the request could come in while in the middle of getting the frame tree, // which is asynchronous, so wait for it to be fetched - const frame = await this._getAutFrame() + if (this.gettingFrameTree) { + debugVerbose('awaiting frame tree') + + await this.gettingFrameTree + } + + const frame = _.find(this.frameTree?.childFrames || [], ({ frame }) => { + return frame?.name?.startsWith(AUT_FRAME_NAME_IDENTIFIER) + }) as HasFrame | undefined if (frame) { - return frame.id === frameId + return frame.frame.id === frameId } return false @@ -468,15 +477,29 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { await this.gettingFrameTree } - const frame = _.find(this.frameTree?.childFrames || [], (item: HasFrame) => { - return item.frame?.name?.startsWith('Your project:') + 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 - return frame?.frame + // 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) - return undefined + throw new Error('Could not find AUT frame') } } @@ -620,39 +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': - { - const frame = await this._getAutFrame() - - return cdpGetUrl(this.sendDebuggerCommandFn, this.executionContexts, frame!) - } + return cdpGetUrl(this.sendDebuggerCommandFn, this.executionContexts, await this._getAutFrame()) case 'reload:aut:frame': - { - const frame = await this._getAutFrame() - - await cdpReloadFrame(this.sendDebuggerCommandFn, this.executionContexts, frame!, data.forceReload) - - return - } + return cdpReloadFrame(this.sendDebuggerCommandFn, this.executionContexts, await this._getAutFrame(), data.forceReload) case 'navigate:aut:history': - { - const frame = await this._getAutFrame() - - return cdpNavigateHistory(this.sendDebuggerCommandFn, this.executionContexts, frame!, data.historyNumber!) - } + return cdpNavigateHistory(this.sendDebuggerCommandFn, this.executionContexts, await this._getAutFrame(), data.historyNumber) case 'get:aut:title': - { - const frame = await this._getAutFrame() - - return cdpGetFrameTitle(this.sendDebuggerCommandFn, this.executionContexts, frame!) - } + 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/cdp_automation_spec.ts b/packages/server/test/unit/browsers/cdp_automation_spec.ts index 3cf806489602..9f2db9296289 100644 --- a/packages/server/test/unit/browsers/cdp_automation_spec.ts +++ b/packages/server/test/unit/browsers/cdp_automation_spec.ts @@ -606,6 +606,21 @@ context('lib/browsers/cdp_automation', () => { 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', @@ -620,20 +635,6 @@ context('lib/browsers/cdp_automation', () => { }, }) - // @ts-expect-error - cdpAutomation.frameTree = { - childFrames: [ - { - // @ts-expect-error - frame: { - id: '1', - name: 'Your project: foobar', - url: 'http://localhost:3500/fixtures/dom.html', - }, - }, - ], - } - const resp = await this.onRequest('get:aut:url') expect(resp).to.equal('http://localhost:3500/fixtures/dom.html') @@ -645,12 +646,27 @@ context('lib/browsers/cdp_automation', () => { }) it('fails silently if the frame cannot be found', async function () { - expect(this.onRequest('get:aut:url')).to.be.rejectedWith('Unable to find valid context for frame') + 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: { @@ -658,20 +674,6 @@ context('lib/browsers/cdp_automation', () => { }, }) - // @ts-expect-error - cdpAutomation.frameTree = { - childFrames: [ - { - // @ts-expect-error - frame: { - id: '1', - name: 'Your project: foobar', - url: 'http://localhost:3500/fixtures/dom.html', - }, - }, - ], - } - const resp = await this.onRequest('reload:aut:frame', { forceReload: false }) expect(resp).to.be.undefined @@ -683,6 +685,21 @@ context('lib/browsers/cdp_automation', () => { }) 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: { @@ -690,20 +707,6 @@ context('lib/browsers/cdp_automation', () => { }, }) - // @ts-expect-error - cdpAutomation.frameTree = { - childFrames: [ - { - // @ts-expect-error - frame: { - id: '1', - name: 'Your project: foobar', - url: 'http://localhost:3500/fixtures/dom.html', - }, - }, - ], - } - const resp = await this.onRequest('reload:aut:frame', { forceReload: true }) expect(resp).to.be.undefined @@ -715,12 +718,27 @@ context('lib/browsers/cdp_automation', () => { }) it('fails if the frame cannot be found', async function () { - expect(this.onRequest('reload:aut:frame', { forceReload: false })).to.be.rejectedWith('Unable to find valid context for frame') + 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: { @@ -728,20 +746,6 @@ context('lib/browsers/cdp_automation', () => { }, }) - // @ts-expect-error - cdpAutomation.frameTree = { - childFrames: [ - { - // @ts-expect-error - frame: { - id: '1', - name: 'Your project: foobar', - url: 'http://localhost:3500/fixtures/dom.html', - }, - }, - ], - } - const resp = await this.onRequest('navigate:aut:history', { historyNumber: 1 }) expect(resp).to.be.undefined @@ -753,12 +757,27 @@ context('lib/browsers/cdp_automation', () => { }) it('fails if the frame cannot be found', async function () { - expect(this.onRequest('navigate:aut:history', { historyNumber: 1 })).to.be.rejectedWith('Unable to find valid context for frame') + 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', @@ -773,20 +792,6 @@ context('lib/browsers/cdp_automation', () => { }, }) - // @ts-expect-error - cdpAutomation.frameTree = { - childFrames: [ - { - // @ts-expect-error - frame: { - id: '1', - name: 'Your project: foobar', - url: 'http://localhost:3500/fixtures/dom.html', - }, - }, - ], - } - const resp = await this.onRequest('get:aut:title') expect(resp).to.equal('mock title') @@ -798,7 +803,7 @@ context('lib/browsers/cdp_automation', () => { }) it('fails if the frame cannot be found', async function () { - expect(this.onRequest('get:aut:title')).to.be.rejectedWith('Unable to find valid context for frame') + expect(this.onRequest('get:aut:title')).to.be.rejectedWith('Could not find AUT frame') }) }) })