chore: cut over web extension methods to use webdriver BiDi to automate cookie behavior similar to CDP client (#31209)

This commit is contained in:
Bill Glesias
2025-04-17 20:15:01 -04:00
committed by GitHub
parent 570b78694a
commit 0a51e6d209
9 changed files with 2011 additions and 1127 deletions
-229
View File
@@ -1,25 +1,9 @@
const get = require('lodash/get')
const map = require('lodash/map')
const pick = require('lodash/pick')
const once = require('lodash/once')
const Promise = require('bluebird')
const browser = require('webextension-polyfill')
const { cookieMatches } = require('@packages/server/lib/automation/util')
const client = require('./client')
const util = require('../../lib/util')
const COOKIE_PROPS = ['url', 'name', 'path', 'secure', 'domain']
const GET_ALL_PROPS = COOKIE_PROPS.concat(['session', 'storeId'])
// https://developer.chrome.com/extensions/cookies#method-set
const SET_PROPS = COOKIE_PROPS.concat(['value', 'httpOnly', 'expirationDate', 'sameSite'])
const httpRe = /^http/
// normalize into null when empty array
const firstOrNull = (cookies) => {
return cookies[0] != null ? cookies[0] : null
}
const checkIfFirefox = async () => {
if (!browser || !get(browser, 'runtime.getBrowserInfo')) {
@@ -91,29 +75,8 @@ const connect = function (host, path, extraOpts) {
ws.on('automation:request', (id, msg, data) => {
switch (msg) {
case 'get:cookies':
return invoke('getCookies', id, data)
case 'get:cookie':
return invoke('getCookie', id, data)
case 'set:cookie':
return invoke('setCookie', id, data)
case 'set:cookies':
case 'add:cookies':
return invoke('setCookies', id, data)
case 'clear:cookies':
return invoke('clearCookies', id, data)
case 'clear:cookie':
return invoke('clearCookie', id, data)
case 'is:automation:client:connected':
return invoke('verify', id, data)
case 'focus:browser:window':
return invoke('focus', id)
case 'take:screenshot':
return invoke('takeScreenshot', id)
case 'reset:browser:state':
return invoke('resetBrowserState', id)
case 'reset:browser:tabs:for:next:spec':
return invoke('resetBrowserTabsForNextSpec', id)
default:
return fail(id, { message: `No handler registered for: '${msg}'` })
}
@@ -136,207 +99,15 @@ const connect = function (host, path, extraOpts) {
return ws
}
const setOneCookie = (props) => {
// only get the url if its not already set
if (props.url == null) {
props.url = util.getCookieUrl(props)
}
if (props.hostOnly) {
// If the hostOnly prop is available, delete the domain.
// This will wind up setting a hostOnly cookie based on the calculated cookieURL above.
delete props.domain
}
if (props.domain === 'localhost') {
delete props.domain
}
props = pick(props, SET_PROPS)
return Promise.try(() => {
return browser.cookies.set(props)
})
}
const clearOneCookie = (cookie = {}) => {
const url = util.getCookieUrl(cookie)
const props = { url, name: cookie.name }
const throwError = function (err) {
throw (err != null ? err : new Error(`Removing cookie failed for: ${JSON.stringify(props)}`))
}
return Promise.try(() => {
if (!cookie.name) {
throw new Error(`Removing cookie failed for: ${JSON.stringify(cookie)}. Cookie did not include a name`)
}
return browser.cookies.remove(props)
}).then((details) => {
return cookie
}).catch(throwError)
}
const clearAllCookies = (cookies) => {
return Promise.mapSeries(cookies, clearOneCookie)
}
const automation = {
connect,
getAll (filter = {}) {
filter = pick(filter, GET_ALL_PROPS)
// Firefox's filtering doesn't match the behavior we want, so we do it
// ourselves. for example, getting { domain: example.com } cookies will
// return cookies for example.com and all subdomains, whereas we want an
// exact match for only "example.com".
return Promise.try(() => {
return browser.cookies.getAll({ url: filter.url })
.then((cookies) => {
return cookies.filter((cookie) => {
return cookieMatches(cookie, filter)
})
})
})
},
getCookies (filter, fn) {
return this.getAll(filter)
.then(fn)
},
getCookie (filter, fn) {
return this.getAll(filter)
.then(firstOrNull)
.then(fn)
},
setCookie (props = {}, fn) {
return setOneCookie(props)
.then(fn)
},
setCookies (propsArr = [], fn) {
return Promise.mapSeries(propsArr, setOneCookie)
.then(fn)
},
clearCookie (filter, fn) {
return this.getCookie(filter)
.then((cookie) => {
if (!cookie) return null
return clearOneCookie(cookie)
})
.then(fn)
},
clearCookies (cookies, fn) {
return clearAllCookies(cookies)
.then(fn)
},
focus (fn) {
// lets just make this simple and whatever is the current
// window bring that into focus
//
// TODO: if we REALLY want to be nice its possible we can
// figure out the exact window that's running Cypress but
// that's too much work with too little value at the moment
return Promise.try(() => {
return browser.windows.getCurrent()
}).then((window) => {
return browser.windows.update(window.id, { focused: true })
}).then(fn)
},
resetBrowserState (fn) {
// We remove browser data. Firefox goes through this path, while chrome goes through cdp automation
// Note that firefox does not support fileSystems or serverBoundCertificates
// (https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/DataTypeSet).
return browser.browsingData.remove({}, { cache: true, cookies: true, downloads: true, formData: true, history: true, indexedDB: true, localStorage: true, passwords: true, pluginData: true, serviceWorkers: true }).then(fn)
},
resetBrowserTabsForNextSpec (callback) {
return Promise.try(() => {
return browser.windows.getCurrent({ populate: true })
}).then(async (currentWindowInfo) => {
const windows = await browser.windows.getAll().catch(() => [])
for (const window of windows) {
// remove/close the window if it's not the current window
if (window.id !== currentWindowInfo.id) {
// tslint:disable-next-line:no-empty
await browser.windows.remove(window.id).catch(() => {})
}
}
return currentWindowInfo
}).then(async (currentWindowInfo) => {
let newTabId = null
try {
// in versions of Firefox 124 and up, firefox no longer creates a new tab for us when we close all tabs in the browser.
// to keep change minimal and backwards compatible, we are creating an 'about:blank' tab here to keep the behavior consistent.
// this works in previous versions as well since one tab is left, hence one will not be created for us in Firefox 123 and below
const newAboutBlankTab = await browser.tabs.create({ url: 'about:blank', active: false })
newTabId = newAboutBlankTab.id
} catch (e) {
undefined
}
return browser.tabs.remove(currentWindowInfo.tabs.map((tab) => tab.id).filter((tab) => tab.id !== newTabId))
}).then(callback)
},
query (data) {
const code = `var s; (s = document.getElementById('${data.element}')) && s.textContent`
const queryTab = (tab) => {
return Promise.try(() => {
return browser.tabs.executeScript(tab.id, { code })
}).then((results) => {
if (!results || (results[0] !== data.randomString)) {
throw new Error('Executed script did not return result')
}
})
}
return Promise.try(() => {
return browser.tabs.query({ windowType: 'normal' })
}).filter((tab) => {
// the tab's url must begin with
// http or https so that we filter out
// about:blank and chrome:// urls
// which will throw errors!
return httpRe.test(tab.url)
}).then((tabs) => {
// generate array of promises
return map(tabs, queryTab)
}).any()
},
verify (data, fn) {
return this.query(data)
.then(fn)
},
lastFocusedWindow () {
return Promise.try(() => {
return browser.windows.getLastFocused()
})
},
takeScreenshot (fn) {
return this.lastFocusedWindow()
.then((win) => {
return browser.tabs.captureVisibleTab(win.id, { format: 'png' })
})
.then(fn)
},
}
module.exports = automation
@@ -3,15 +3,11 @@ require('../../spec_helper')
const _ = require('lodash')
const http = require('http')
const socket = require('@packages/socket')
const Promise = require('bluebird')
const mockRequire = require('mock-require')
const client = require('../../../app/v2/client')
const browser = {
cookies: {
set () {},
getAll () {},
remove () {},
onChanged: {
addListener () {},
},
@@ -24,29 +20,10 @@ const browser = {
addListener () {},
},
},
windows: {
getAll () {},
getCurrent () {},
getLastFocused () {},
remove () {},
update () {},
},
runtime: {},
tabs: {
create () {},
query () {},
executeScript () {},
captureVisibleTab () {},
remove () {},
},
browsingData: {
remove () {},
},
webRequest: {
onBeforeSendHeaders: {
addListener () {},
},
},
}
mockRequire('webextension-polyfill', browser)
@@ -56,69 +33,6 @@ const { expect } = require('chai')
const PORT = 12345
const tab1 = {
'active': false,
'audible': false,
'favIconUrl': 'http://localhost:2020/__cypress/static/img/favicon.ico',
'height': 553,
'highlighted': false,
'id': 1,
'incognito': false,
'index': 0,
'mutedInfo': {
'muted': false,
},
'pinned': false,
'selected': false,
'status': 'complete',
'title': 'foobar',
'url': 'http://localhost:2020/__/#tests',
'width': 1920,
'windowId': 1,
}
const tab2 = {
'active': true,
'audible': false,
'favIconUrl': 'http://localhost:2020/__cypress/static/img/favicon.ico',
'height': 553,
'highlighted': true,
'id': 2,
'incognito': false,
'index': 1,
'mutedInfo': {
'muted': false,
},
'pinned': false,
'selected': true,
'status': 'complete',
'title': 'foobar',
'url': 'https://localhost:2020/__/#tests',
'width': 1920,
'windowId': 1,
}
const tab3 = {
'active': true,
'audible': false,
'favIconUrl': 'http://localhost:2020/__cypress/static/img/favicon.ico',
'height': 553,
'highlighted': true,
'id': 2,
'incognito': false,
'index': 1,
'mutedInfo': {
'muted': false,
},
'pinned': false,
'selected': true,
'status': 'complete',
'title': 'foobar',
'url': 'about:blank',
'width': 1920,
'windowId': 1,
}
describe('app/background', () => {
beforeEach(function (done) {
global.window = {}
@@ -293,119 +207,6 @@ describe('app/background', () => {
})
})
context('.getAll', () => {
it('resolves with specific cookie properties', () => {
sinon.stub(browser.cookies, 'getAll').resolves([
{ name: 'key1', value: 'value1', path: '/', domain: 'localhost', secure: true, httpOnly: true, expirationDate: 123 },
{ name: 'key2', value: 'value2', path: '/', domain: 'localhost', secure: false, httpOnly: false, expirationDate: 456 },
{ name: 'key3', value: 'value3', path: '/', domain: 'foobar.com', secure: false, httpOnly: false, expirationDate: 456 },
{ name: 'key4', value: 'value4', path: '/', domain: 'www.foobar.com', secure: false, httpOnly: false, expirationDate: 456 },
])
return background.getAll({ domain: 'foobar.com' })
.then((cookies) => {
expect(cookies).to.deep.eq([
{ name: 'key3', value: 'value3', path: '/', domain: 'foobar.com', secure: false, httpOnly: false, expirationDate: 456 },
])
})
})
})
context('.query', () => {
beforeEach(function () {
this.code = 'var s; (s = document.getElementById(\'__cypress-string\')) && s.textContent'
})
it('resolves on the 1st tab', function () {
sinon.stub(browser.tabs, 'query')
.withArgs({ windowType: 'normal' })
.resolves([tab1])
sinon.stub(browser.tabs, 'executeScript')
.withArgs(tab1.id, { code: this.code })
.resolves(['1234'])
return background.query({
randomString: '1234',
element: '__cypress-string',
})
})
it('resolves on the 2nd tab', function () {
sinon.stub(browser.tabs, 'query')
.withArgs({ windowType: 'normal' })
.resolves([tab1, tab2])
sinon.stub(browser.tabs, 'executeScript')
.withArgs(tab1.id, { code: this.code })
.resolves(['foobarbaz'])
.withArgs(tab2.id, { code: this.code })
.resolves(['1234'])
return background.query({
randomString: '1234',
element: '__cypress-string',
})
})
it('filters out tabs that don\'t start with http', () => {
sinon.stub(browser.tabs, 'query')
.resolves([tab3])
return background.query({
string: '1234',
element: '__cypress-string',
})
.then(() => {
throw new Error('should have failed')
}).catch((err) => {
// we good if this hits
expect(err).to.be.instanceof(Promise.RangeError)
})
})
it('rejects if no tab matches', function () {
sinon.stub(browser.tabs, 'query')
.withArgs({ windowType: 'normal' })
.resolves([tab1, tab2])
sinon.stub(browser.tabs, 'executeScript')
.withArgs(tab1.id, { code: this.code })
.resolves(['foobarbaz'])
.withArgs(tab2.id, { code: this.code })
.resolves(['foobarbaz2'])
return background.query({
string: '1234',
element: '__cypress-string',
})
.then(() => {
throw new Error('should have failed')
}).catch((err) => {
// we good if this hits
expect(err.length).to.eq(2)
expect(err).to.be.instanceof(Promise.AggregateError)
})
})
it('rejects if no tabs were found', () => {
sinon.stub(browser.tabs, 'query')
.resolves([])
return background.query({
string: '1234',
element: '__cypress-string',
})
.then(() => {
throw new Error('should have failed')
}).catch((err) => {
// we good if this hits
expect(err).to.be.instanceof(Promise.RangeError)
})
})
})
context('integration', () => {
beforeEach(function (done) {
done = _.once(done)
@@ -421,329 +222,6 @@ describe('app/background', () => {
this.client = background.connect(`http://localhost:${PORT}`, '/__socket')
})
describe('get:cookies', () => {
beforeEach(() => {
sinon.stub(browser.cookies, 'getAll').resolves([
{ cookie: '1', domain: 'example.com' },
{ cookie: '2', domain: 'www.example.com' },
])
})
it('returns cookies that match filter', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq([{ cookie: '1', domain: 'example.com' }])
done()
})
this.server.emit('automation:request', 123, 'get:cookies', { domain: 'example.com' })
})
it('returns all cookies if there is no filter', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq([
{ cookie: '1', domain: 'example.com' },
{ cookie: '2', domain: 'www.example.com' },
])
done()
})
this.server.emit('automation:request', 123, 'get:cookies', {})
})
})
describe('get:cookie', () => {
beforeEach(() => {
sinon.stub(browser.cookies, 'getAll').resolves([
{ name: 'session', value: 'key', path: '/login', domain: 'example.com', secure: true, httpOnly: true, expirationDate: 123 },
])
})
it('returns a specific cookie by name', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq({ name: 'session', value: 'key', path: '/login', domain: 'example.com', secure: true, httpOnly: true, expirationDate: 123 })
done()
})
this.server.emit('automation:request', 123, 'get:cookie', { domain: 'example.com', name: 'session' })
})
it('returns null when no cookie by name is found', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.be.null
done()
})
this.server.emit('automation:request', 123, 'get:cookie', { domain: 'example.com', name: 'doesNotExist' })
})
})
describe('set:cookie', () => {
beforeEach(() => {
browser.runtime.lastError = { message: 'some error' }
return sinon.stub(browser.cookies, 'set')
.withArgs({ domain: 'example.com', name: 'session', value: 'key', path: '/', secure: false, url: 'http://example.com/' })
.resolves(
{ name: 'session', value: 'key', path: '/', domain: 'example', secure: false, httpOnly: false },
)
.withArgs({ url: 'https://www.example.com', name: 'session', value: 'key' })
.resolves(
{ name: 'session', value: 'key', path: '/', domain: 'example.com', secure: true, httpOnly: false },
)
// 'domain' cannot not set when it's localhost
.withArgs({ name: 'foo', value: 'bar', secure: true, path: '/foo', url: 'https://localhost/foo' })
.rejects({ message: 'some error' })
})
it('resolves with the cookie details', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq({ name: 'session', value: 'key', path: '/', domain: 'example', secure: false, httpOnly: false })
done()
})
this.server.emit('automation:request', 123, 'set:cookie', { domain: 'example.com', name: 'session', secure: false, value: 'key', path: '/' })
})
it('does not set url when already present', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq({ name: 'session', value: 'key', path: '/', domain: 'example.com', secure: true, httpOnly: false })
done()
})
this.server.emit('automation:request', 123, 'set:cookie', { url: 'https://www.example.com', name: 'session', value: 'key' })
})
it('rejects with error', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.__error).to.eq('some error')
done()
})
this.server.emit('automation:request', 123, 'set:cookie', { name: 'foo', value: 'bar', domain: 'localhost', secure: true, path: '/foo' })
})
})
describe('clear:cookies', () => {
beforeEach(() => {
browser.runtime.lastError = { message: 'some error' }
return sinon.stub(browser.cookies, 'remove')
.callsFake(function () {
// eslint-disable-next-line no-console
console.log('unstubbed browser.cookies.remove', ...arguments)
})
.withArgs({ url: 'https://example.com', name: 'foo' })
.resolves(
{ name: 'session', url: 'https://example.com/', storeId: '123' },
)
.withArgs({ name: 'foo', url: 'http://example.com/foo' })
.resolves(
{ name: 'foo', url: 'https://example.com/foo', storeId: '123' },
)
.withArgs({ name: 'noDetails', url: 'http://no.details' })
.resolves(null)
.withArgs({ name: 'shouldThrow', url: 'http://should.throw' })
.rejects({ message: 'some error' })
})
it('resolves with array of removed cookies', function (done) {
const cookieArr = [{ domain: 'example.com', name: 'foo', secure: true }]
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq(cookieArr)
done()
})
this.server.emit('automation:request', 123, 'clear:cookies', cookieArr)
})
it('rejects when no cookie.name', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.__error).to.contain('did not include a name')
done()
})
this.server.emit('automation:request', 123, 'clear:cookies', [{ domain: 'should.throw' }])
})
it('rejects with error thrown in browser.cookies.remove', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.__error).to.eq('some error')
done()
})
this.server.emit('automation:request', 123, 'clear:cookies', [{ domain: 'should.throw', name: 'shouldThrow' }])
})
it('doesnt fail when no found cookie', function (done) {
const cookieArr = [{ domain: 'no.details', name: 'noDetails' }]
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq(cookieArr)
done()
})
this.server.emit('automation:request', 123, 'clear:cookies', cookieArr)
})
})
describe('clear:cookie', () => {
beforeEach(() => {
browser.runtime.lastError = { message: 'some error' }
sinon.stub(browser.cookies, 'getAll').resolves([
{ name: 'session', value: 'key', path: '/', domain: 'example.com', secure: true, httpOnly: true, expirationDate: 123 },
])
return sinon.stub(browser.cookies, 'remove')
.withArgs({ name: 'session', url: 'https://example.com/' })
.resolves(
{ name: 'session', url: 'https://example.com/', storeId: '123' },
)
.withArgs({ name: 'shouldThrow', url: 'http://cdn.github.com/assets' })
.rejects({ message: 'some error' })
})
it('resolves single removed cookie', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.deep.eq(
{ name: 'session', value: 'key', path: '/', domain: 'example.com', secure: true, httpOnly: true, expirationDate: 123 },
)
done()
})
this.server.emit('automation:request', 123, 'clear:cookie', { domain: 'example.com', name: 'session' })
})
it('returns null when no cookie by name is found', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.be.null
done()
})
this.server.emit('automation:request', 123, 'clear:cookie', { domain: 'example.com', name: 'doesNotExist' })
})
it('rejects with error', function (done) {
browser.cookies.getAll.resolves([
{ name: 'shouldThrow', value: 'key', path: '/assets', domain: 'cdn.github.com', secure: false, httpOnly: true, expirationDate: 123 },
])
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.__error).to.eq('some error')
done()
})
this.server.emit('automation:request', 123, 'clear:cookie', { domain: 'cdn.github.com', name: 'shouldThrow' })
})
})
describe('is:automation:client:connected', () => {
beforeEach(() => {
return sinon.stub(browser.tabs, 'query')
.withArgs({ url: 'CHANGE_ME_HOST/*', windowType: 'normal' })
.resolves([])
})
it('queries url and resolve', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.be.undefined
done()
})
this.server.emit('automation:request', 123, 'is:automation:client:connected')
})
})
describe('focus:browser:window', () => {
beforeEach(() => {
sinon.stub(browser.windows, 'getCurrent').resolves({ id: '10' })
sinon.stub(browser.windows, 'update').withArgs('10', { focused: true }).resolves()
})
it('focuses the current window', function (done) {
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.be.undefined
expect(browser.windows.getCurrent).to.be.called
expect(browser.windows.update).to.be.called
done()
})
this.server.emit('automation:request', 123, 'focus:browser:window')
})
})
describe('take:screenshot', () => {
beforeEach(() => {
return sinon.stub(browser.windows, 'getLastFocused').resolves({ id: 1 })
})
afterEach(() => {
return delete browser.runtime.lastError
})
it('resolves with screenshot', function (done) {
sinon.stub(browser.tabs, 'captureVisibleTab')
.withArgs(1, { format: 'png' })
.resolves('foobarbaz')
this.socket.on('automation:response', (id, obj = {}) => {
expect(id).to.eq(123)
expect(obj.response).to.eq('foobarbaz')
done()
})
this.server.emit('automation:request', 123, 'take:screenshot')
})
it('rejects with browser.runtime.lastError', function (done) {
sinon.stub(browser.tabs, 'captureVisibleTab').withArgs(1, { format: 'png' }).rejects(new Error('some error'))
this.socket.on('automation:response', (id, obj) => {
expect(id).to.eq(123)
expect(obj.__error).to.eq('some error')
done()
})
this.server.emit('automation:request', 123, 'take:screenshot')
})
})
describe('reset:browser:state', () => {
beforeEach(() => {
sinon.stub(browser.browsingData, 'remove').withArgs({}, { cache: true, cookies: true, downloads: true, formData: true, history: true, indexedDB: true, localStorage: true, passwords: true, pluginData: true, serviceWorkers: true }).resolves()
@@ -762,84 +240,5 @@ describe('app/background', () => {
this.server.emit('automation:request', 123, 'reset:browser:state')
})
})
describe('reset:browser:tabs:for:next:spec', () => {
beforeEach(() => {
sinon.stub(browser.windows, 'getCurrent').withArgs({ populate: true }).resolves({ id: '10', tabs: [{ id: '1' }, { id: '2' }, { id: '3' }] })
sinon.stub(browser.tabs, 'remove').withArgs(['1', '2', '3']).resolves()
sinon.stub(browser.tabs, 'create').withArgs({ url: 'about:blank', active: false }).resolves({
id: 'new-tab',
})
})
// @see https://github.com/cypress-io/cypress/issues/29172 for Firefox versions 124 and up
it('closes the tabs in the current browser window and creates a new "about:blank" tab', function (done) {
sinon.stub(browser.windows, 'getAll').resolves([{ id: '10' }])
this.socket.on('automation:response', (id, obj) => {
expect(id).to.eq(123)
expect(obj.response).to.be.undefined
expect(browser.windows.getCurrent).to.be.called
expect(browser.tabs.remove).to.be.calledWith(['1', '2', '3'])
expect(browser.tabs.create).to.be.calledWith({ url: 'about:blank', active: false })
done()
})
this.server.emit('automation:request', 123, 'reset:browser:tabs:for:next:spec')
})
it('closes any extra windows', function (done) {
sinon.stub(browser.windows, 'getAll').resolves([{ id: '9' }, { id: '10' }, { id: '11' }])
sinon.stub(browser.windows, 'remove').resolves()
this.socket.on('automation:response', (id, obj) => {
expect(id).to.eq(123)
expect(obj.response).to.be.undefined
expect(browser.windows.remove).to.be.calledWith('9')
expect(browser.windows.remove).to.be.calledWith('11')
expect(browser.windows.remove).not.to.be.calledWith('10')
done()
})
this.server.emit('automation:request', 123, 'reset:browser:tabs:for:next:spec')
})
it('does not fail if we are unable to close the window', function (done) {
sinon.stub(browser.windows, 'getAll').resolves([{ id: '9' }, { id: '10' }, { id: '11' }])
sinon.stub(browser.windows, 'remove').rejects()
this.socket.on('automation:response', (id, obj) => {
expect(id).to.eq(123)
expect(obj.response).to.be.undefined
expect(browser.windows.remove).to.be.calledWith('9')
expect(browser.windows.remove).to.be.calledWith('11')
expect(browser.windows.remove).not.to.be.calledWith('10')
done()
})
this.server.emit('automation:request', 123, 'reset:browser:tabs:for:next:spec')
})
it('does not fail if we are unable to retrieve the windows', function (done) {
sinon.stub(browser.windows, 'getAll').rejects()
sinon.stub(browser.windows, 'remove')
this.socket.on('automation:response', (id, obj) => {
expect(id).to.eq(123)
expect(obj.response).to.be.undefined
expect(browser.windows.remove).not.to.be.called
done()
})
this.server.emit('automation:request', 123, 'reset:browser:tabs:for:next:spec')
})
})
})
})
+5 -5
View File
@@ -1,5 +1,5 @@
import type playwright from 'playwright-webkit'
import { domainMatch } from 'tough-cookie'
import { domainMatch, pathMatch } from 'tough-cookie'
// @ts-ignore
export type CyCookie = Pick<chrome.cookies.Cookie, 'name' | 'value' | 'expirationDate' | 'hostOnly' | 'domain' | 'path' | 'secure' | 'httpOnly'> & {
@@ -12,16 +12,16 @@ export type CyCookie = Pick<chrome.cookies.Cookie, 'name' | 'value' | 'expiratio
// @ts-ignore
export type CyCookieFilter = chrome.cookies.GetAllDetails
export const cookieMatches = (cookie: CyCookie | playwright.Cookie, filter: CyCookieFilter) => {
if (filter.domain && !domainMatch(filter.domain, cookie.domain)) {
export const cookieMatches = (cookie: CyCookie | playwright.Cookie, filter?: CyCookieFilter) => {
if (filter?.domain && !domainMatch(filter?.domain, cookie.domain)) {
return false
}
if (filter.path && filter.path !== cookie.path) {
if (filter?.path && !pathMatch(filter.path, cookie.path)) {
return false
}
if (filter.name && filter.name !== cookie.name) {
if (filter?.name && filter?.name !== cookie.name) {
return false
}
+363 -3
View File
@@ -1,6 +1,12 @@
import debugModule from 'debug'
import type { Automation } from '../automation'
import toInteger from 'lodash/toInteger'
import isNumber from 'lodash/isNumber'
import { isHostOnlyCookie } from './cdp_automation'
import { cookieMatches } from '../automation/util'
import { bidiKeyPress } from '../automation/commands/key_press'
import { AutomationNotImplemented } from '../automation/automation_not_implemented'
import type { Automation } from '../automation'
import type { BrowserPreRequest, BrowserResponseReceived, ResourceType } from '@packages/proxy'
import type { AutomationMiddleware, AutomationCommands } from '@packages/types'
import type { Client as WebDriverClient } from 'webdriver'
@@ -9,12 +15,38 @@ import type {
NetworkResponseStartedParameters,
NetworkResponseCompletedParameters,
NetworkFetchErrorParameters,
NetworkCookie,
BrowsingContextInfo,
NetworkSameSite,
} from 'webdriver/build/bidi/localTypes'
import type { CyCookie } from './webkit-automation'
import { bidiKeyPress } from '../automation/commands/key_press'
const BIDI_DEBUG_NAMESPACE = 'cypress:server:browsers:bidi_automation'
const BIDI_COOKIE_DEBUG_NAMESPACE = `${BIDI_DEBUG_NAMESPACE}:cookies`
const BIDI_SCREENSHOT_DEBUG_NAMESPACE = `${BIDI_DEBUG_NAMESPACE}:screenshot`
const debug = debugModule(BIDI_DEBUG_NAMESPACE)
const debugCookies = debugModule(BIDI_COOKIE_DEBUG_NAMESPACE)
const debugScreenshot = debugModule(BIDI_SCREENSHOT_DEBUG_NAMESPACE)
// if the filter is not an exact match OR, if looselyMatchCookiePath is enabled, doesn't include the path.
// ex: /foo/bar/baz path should include cookies for /foo/bar/baz, /foo/bar, /foo, and /
// this is shipped in remoteTypes within webdriver but it isn't exported, so we need to redefine the type
interface StoragePartialCookie extends Record<string, unknown> {
name: string
value: {
type: 'string'
value: string
}
domain: string
path: string
httpOnly: boolean
hostOnly?: boolean
secure: boolean
sameSite: NetworkSameSite
expiry?: number
}
const debug = debugModule('cypress:server:browsers:bidi_automation')
const debugVerbose = debugModule('cypress-verbose:server:browsers:bidi_automation')
// NOTE: these types will eventually be generated automatically via the 'webdriver' package
@@ -61,6 +93,100 @@ const normalizeResourceType = (type: RequestInitiatorType): ResourceType => {
}
}
function convertSameSiteBiDiToExtension (str: NetworkSameSite) {
if (str === 'none') {
return 'no_restriction'
}
return str
}
function convertSameSiteExtensionToBiDi (str: CyCookie['sameSite']) {
if (str === 'no_restriction') {
return 'none'
}
// if no value, default to 'none' as this is the browser default in firefox specifically.
// Every other browser defaults to 'lax'
return str === undefined ? 'none' : str
}
// used to normalize cookies to CyCookie before returning them through the automation client
const convertBiDiCookieToCyCookie = (cookie: NetworkCookie): CyCookie => {
const cyCookie: CyCookie = {
name: cookie.name,
value: cookie.value.value,
domain: cookie.domain,
path: cookie.path,
httpOnly: cookie.httpOnly,
hostOnly: !!isHostOnlyCookie(cookie),
expirationDate: cookie.expiry ?? undefined,
secure: cookie.secure,
sameSite: convertSameSiteBiDiToExtension(cookie.sameSite),
}
debugCookies(`parsed BiDi cookie %o to cy cookie %o`, cookie, cyCookie)
return cyCookie
}
const convertCyCookieToBiDiCookie = (cookie: CyCookie): StoragePartialCookie => {
const cookieToSet: StoragePartialCookie = {
name: cookie.name,
value: {
type: 'string',
value: cookie.value,
},
domain: cookie.domain,
path: cookie.path,
httpOnly: cookie.httpOnly,
secure: cookie.secure,
sameSite: convertSameSiteExtensionToBiDi(cookie.sameSite),
// BiDi cookie expiry is in seconds from EPOCH, but sometimes the automation client feeds in a float and BiDi does not know how to handle it.
// If trying to set a float on the expiry time in BiDi, the setting silently fails.
expiry: (cookie.expirationDate === -Infinity ? 0 : (isNumber(cookie.expirationDate) ? toInteger(cookie.expirationDate) : null)) ?? undefined,
}
if (!cookie.hostOnly && isHostOnlyCookie(cookie)) {
cookieToSet.domain = `.${cookie.domain}`
}
if (cookie.hostOnly && !isHostOnlyCookie(cookie)) {
cookieToSet.hostOnly = false
}
debugCookies(`parsed cy cookie %o to BiDi cookie %o`, cookie, cookieToSet)
return cookieToSet
}
const buildBiDiClearCookieFilterFromCyCookie = (cookie: CyCookie): StoragePartialCookie => {
const cookieToClearFilter: StoragePartialCookie = {
name: cookie.name,
value: {
type: 'string',
value: cookie.value,
},
domain: cookie.domain,
path: cookie.path,
httpOnly: cookie.httpOnly,
secure: cookie.secure,
sameSite: convertSameSiteExtensionToBiDi(cookie.sameSite),
}
if (!cookie.hostOnly && isHostOnlyCookie(cookie)) {
cookieToClearFilter.domain = `.${cookie.domain}`
}
if (cookie.hostOnly && !isHostOnlyCookie(cookie)) {
cookieToClearFilter.hostOnly = false
}
debugCookies(`built filter to clear cookies from cy cookie %o: %o`, cookie, cookieToClearFilter)
return cookieToClearFilter
}
export class BidiAutomation {
// events needed to subscribe to in order for our BiDi automation to work properly
static BIDI_EVENTS = [
@@ -272,6 +398,101 @@ export class BidiAutomation {
this.automation.onRemoveBrowserPreRequest?.(params.request.request)
}
private async getAllCookiesMatchingFilter (filter?: {
name?: string
domain?: string
path?: string
url?: string
}) {
let secure: boolean | undefined = undefined
if (filter?.url) {
const url = new URL(filter.url)
filter.domain = url.hostname
// if we are in a non-secure context, we do NOT want to get secure cookies and apply them,
// but non-secure cookies can be applied in a secure context.
if (url.protocol === 'http:') {
secure = false
}
if (url.pathname) {
filter.path = url.pathname
}
}
/**
*
* filter for BiDI storageGetCookies gets the EXACT domain / path of the cookie.
* Cypress expects all cookies that apply to that domain / path hierarchy to be returned.
*
* Domain example:
* For instance, domain www.foobar.com would have cookies with .foobar.com applied,
* but sending domain=www.foobar.com to storageGetCookies would not return cookies with .foobar.com domain.
*
* Path example
* For instance, given everything equal except path, given 3 cookies paths:
* /
* /cookies
* /cookies/foo
*
* passing path=/cookies/foo will ONLY return cookies matching the exact path of cookies/foo and not its parent hierarchy
*/
const BiDiCookieFilter = {
...(filter?.name !== undefined ? {
name: filter.name,
} : {}),
...(secure !== undefined ? {
secure,
} : {}),
}
const { cookies } = await this.webDriverClient.storageGetCookies({ filter: BiDiCookieFilter })
debugCookies(`found cookies: %o matching filter: %o`, cookies, BiDiCookieFilter)
// convert the BiDi Cookies to CyCookies
const normalizedCookies: CyCookie[] = cookies.map((cookie) => convertBiDiCookieToCyCookie(cookie))
// because of the above comment on the BiDi API, we get ALL cookies not filtering by domain
// (name filter is safe to reduce the payload coming back)
// and filter out all cookies that apply to the given domain, path, and name (which should already be done)
const filteredCookies = normalizedCookies.filter((cookie) => cookieMatches(cookie, filter))
debugCookies(`filtered additional cookies based on domain, path, or name: %o`, filteredCookies)
// print additional information if additional filtering was performed and differs from that returned from BiDi
if (debugModule.enabled(BIDI_COOKIE_DEBUG_NAMESPACE) && filteredCookies.length !== normalizedCookies.length) {
debugCookies(`filtered additional cookies based on domain, path, or name: %o`, filteredCookies)
}
return filteredCookies
}
private async clearCookies (cookie: CyCookie) {
const {
domain,
path,
name,
} = cookie
// get the cookie we are clearing from the BiDi API to make sure it exists
const cookieToBeCleared = (await this.getAllCookiesMatchingFilter({
domain,
path,
name,
}))[0]
debugCookies(`found cookie matching %o filter: %o`, { domain, name, path }, cookieToBeCleared)
if (!cookieToBeCleared) return
// if it does, convert it to a BiDi cookie filter and delete the cookie
await this.webDriverClient.storageDeleteCookies({
filter: buildBiDiClearCookieFilterFromCyCookie(cookieToBeCleared),
})
return cookieToBeCleared
}
close () {
this.webDriverClient.off('network.beforeRequestSent', this.onBeforeRequestSent)
this.webDriverClient.off('network.responseStarted', this.onResponseStarted)
@@ -290,6 +511,145 @@ export class BidiAutomation {
debugVerbose('automation command \'%s\' requested with data: %O', message, data)
debug('BiDi middleware handling msg `%s` for top context %s', message, this.topLevelContextId)
switch (message) {
case 'get:cookies':
{
debugCookies(`get:cookies %o`, data)
const cookies = await this.getAllCookiesMatchingFilter(data)
return cookies
}
case 'get:cookie':
{
const cookies = await this.getAllCookiesMatchingFilter(data)
return cookies[0] || null
}
case 'set:cookie':
{
debugCookies(`set:cookie %o`, data)
await this.webDriverClient.storageSetCookie({
cookie: convertCyCookieToBiDiCookie(data),
})
const cookies = await this.getAllCookiesMatchingFilter(data)
return cookies[0] || null
}
case 'add:cookies':
debugCookies(`add:cookies %o`, data)
await Promise.all(data.map((cookie) => {
return this.webDriverClient.storageSetCookie({
cookie: convertCyCookieToBiDiCookie(cookie),
})
}))
return
case 'set:cookies':
await this.webDriverClient.storageDeleteCookies({})
debugCookies(`set:cookies %o`, data)
await Promise.all(data.map((cookie) => {
return this.webDriverClient.storageSetCookie({
cookie: convertCyCookieToBiDiCookie(cookie),
})
}))
return
case 'clear:cookie':
{
debugCookies(`clear:cookie %o`, data)
const clearedCookie = await this.clearCookies(data)
return clearedCookie
}
case 'clear:cookies':
{
debugCookies(`clear:cookies %o`, data)
const cookiesToBeCleared: CyCookie[] = await Promise.all(data.map(async (cookie: CyCookie) => this.clearCookies(cookie)))
// clearCookies can return undefined so we filter those values out
return cookiesToBeCleared.filter(Boolean)
}
case 'is:automation:client:connected':
return true
case 'take:screenshot':
{
const { contexts } = await this.webDriverClient.browsingContextGetTree({})
const cypressContext = contexts[0].context
// make sure the main cypress context is focused before taking a screenshot
await this.webDriverClient.browsingContextActivate({
context: cypressContext,
})
const { data: base64EncodedScreenshot } = await this.webDriverClient.browsingContextCaptureScreenshot({
context: contexts[0].context,
format: {
type: 'png',
},
})
debugScreenshot(`take:screenshot base64 encoded value of context %s: %s`, contexts[0].context, base64EncodedScreenshot)
return `data:image/png;base64,${base64EncodedScreenshot}`
}
case 'reset:browser:state':
// FIXME: patch this for now just to get clean cookies between tests
// we really need something similar to the Storage.clearDataForOrigin and Network.clearBrowserCache methods here.
// For now we can forward to the web extension or the web extension https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/remove API
debug('reset:browser:state')
// await this.webDriverClient.storageDeleteCookies({})
// to accomplish this, we will throw an AutomationNotImplemented error to let the web extension handle it.
throw new AutomationNotImplemented(message, 'BiDiAutomation')
case 'reset:browser:tabs:for:next:spec':
{
const { contexts } = await this.webDriverClient.browsingContextGetTree({})
if (data.shouldKeepTabOpen) {
// create a new context for the next spec to run
const { context } = await this.webDriverClient.browsingContextCreate({
type: 'tab',
})
debug(`reset:browser:tabs:for:next:spec shouldKeepTabOpen=true. Created new context: %s`, context)
}
// CLOSE ALL BUT THE NEW CONTEXT, which makes it active
// also do not need to navigate to about:blank as this happens by default
for (const context of contexts) {
debug(`reset:browser:tabs:for:next:spec closing context: %s`, context.context)
await this.webDriverClient.browsingContextClose({
context: context.context,
})
}
}
return
case 'focus:browser:window':
{
const { contexts } = await this.webDriverClient.browsingContextGetTree({})
// TODO: just focus the AUT context window that we already have as opposed to the zero-ith frame
const cypressContext = contexts[0].context
await this.webDriverClient.browsingContextActivate({
context: cypressContext,
})
debug(`focus:browser:window focused context: %s`, cypressContext)
}
return
case 'key:press':
if (this.autContextId) {
await bidiKeyPress(data, this.webDriverClient, this.autContextId, this.topLevelContextId)
+4 -2
View File
@@ -8,12 +8,14 @@ const debug = Debug('cypress:server:browsers:firefox-util')
let webdriverClient: WebDriverClient
async function connectToNewSpecBiDi (options, automation: Automation, browserBiDiClient: BidiAutomation) {
// when connecting to a new spec, we need to re register the existing bidi client to the automation client
// as the automation client resets its middleware between specs in run mode
debug('firefox: reconnecting to blank tab')
const { contexts } = await webdriverClient.browsingContextGetTree({})
browserBiDiClient.setTopLevelContextId(contexts[0].context)
debug('registering middleware')
// when connecting to a new spec, we need to re register the existing bidi client to the automation client
// as the automation client resets its middleware between specs in run mode
automation.use(browserBiDiClient.automationMiddleware)
await options.onInitializeNewBrowserTab()
-3
View File
@@ -390,9 +390,6 @@ export function clearInstanceState (options: GracefulShutdownOptions = {}) {
export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) {
debug('connectToNewSpec bidi')
await firefoxUtil.connectToNewSpecBiDi(options, automation, browserBidiClient!)
debug('registering middleware')
automation.use(browserBidiClient!.automationMiddleware)
}
export function connectToExisting () {
File diff suppressed because it is too large Load Diff
@@ -285,6 +285,7 @@ describe('lib/browsers/firefox', () => {
// make sure Bidi gets created
expect(BidiAutomation.create).to.be.calledWith(wdInstance, this.automation)
expect(this.automation.use).to.have.been.calledWith(bidiAutomationClient.automationMiddleware)
expect(bidiAutomationClient.setTopLevelContextId).to.be.calledWith(mockContextId)
})
-282
View File
@@ -2,7 +2,6 @@ require('../spec_helper')
const _ = require('lodash')
const path = require('path')
const Promise = require('bluebird')
const httpsAgent = require('https-proxy-agent')
const socketIo = require('@packages/socket/lib/browser')
const Fixtures = require('@tooling/system-tests')
@@ -122,287 +121,6 @@ describe('lib/socket', () => {
})
})
context('on(automation:request)', () => {
describe('#onAutomation', () => {
let extensionBackgroundPage = null
let chrome
before(() => {
global.window = {}
chrome = global.chrome = {
cookies: {
set () {},
getAll () {},
remove () {},
onChanged: {
addListener () {},
},
},
downloads: {
onCreated: {
addListener () {},
},
onChanged: {
addListener () {},
},
},
runtime: {
},
tabs: {
query () {},
executeScript () {},
},
webRequest: {
onBeforeSendHeaders: {
addListener () {},
},
},
}
extensionBackgroundPage = require('@packages/extension/app/v2/background')
})
beforeEach(function (done) {
this.socket.socketIo.on('connection', (extClient) => {
this.extClient = extClient
return this.extClient.on('automation:client:connected', () => {
return done()
})
})
return extensionBackgroundPage.connect(this.cfg.proxyUrl, this.cfg.socketIoRoute, { agent: this.agent })
})
afterEach(function () {
return this.extClient.disconnect()
})
after(() => {
chrome = null
})
it('does not return cypress namespace or socket io cookies', function (done) {
sinon.stub(chrome.cookies, 'getAll').yieldsAsync([
{ name: 'foo', value: 'f', path: '/', domain: 'localhost', secure: true, httpOnly: true, expirationDate: 123, a: 'a', b: 'c' },
{ name: 'bar', value: 'b', path: '/', domain: 'localhost', secure: false, httpOnly: false, expirationDate: 456, c: 'a', d: 'c' },
{ name: '__cypress.foo', value: 'b', path: '/', domain: 'localhost', secure: false, httpOnly: false, expirationDate: 456, c: 'a', d: 'c' },
{ name: '__cypress.bar', value: 'b', path: '/', domain: 'localhost', secure: false, httpOnly: false, expirationDate: 456, c: 'a', d: 'c' },
{ name: '__socket', value: 'b', path: '/', domain: 'localhost', secure: false, httpOnly: false, expirationDate: 456, c: 'a', d: 'c' },
])
this.client.emit('automation:request', 'get:cookies', { domain: 'localhost' }, (resp) => {
expect(resp).to.deep.eq({
response: [
{ name: 'foo', value: 'f', path: '/', domain: 'localhost', secure: true, httpOnly: true, expiry: 123 },
{ name: 'bar', value: 'b', path: '/', domain: 'localhost', secure: false, httpOnly: false, expiry: 456 },
],
})
done()
})
})
it('does not clear any namespaced cookies', function (done) {
sinon.stub(chrome.cookies, 'getAll')
.withArgs({ name: 'session', domain: 'google.com' })
.yieldsAsync([
{ name: 'session', value: 'key', path: '/', domain: 'google.com', secure: true, httpOnly: true, expirationDate: 123, a: 'a', b: 'c' },
])
sinon.stub(chrome.cookies, 'remove')
.withArgs({ name: 'session', url: 'https://google.com/' })
.yieldsAsync(
{ name: 'session', url: 'https://google.com/', storeId: '123' },
)
const cookies = [
{ name: 'session', value: 'key', path: '/', domain: 'google.com', secure: true, httpOnly: true, expiry: 123 },
{ domain: 'localhost', name: '__cypress.initial', value: true },
{ domain: 'localhost', name: '__socket', value: '123abc' },
]
return this.client.emit('automation:request', 'clear:cookies', cookies, (resp) => {
expect(resp).to.deep.eq({
response: [
{ name: 'session', value: 'key', path: '/', domain: 'google.com', secure: true, httpOnly: true, expiry: 123 },
],
})
return done()
})
})
it('throws trying to clear namespaced cookie')
it('throws trying to set a namespaced cookie')
it('throws trying to get a namespaced cookie')
it('throws when automation:response has an error in it')
it('throws when no clients connected to automation', function (done) {
this.extClient.disconnect()
return this.client.emit('automation:request', 'get:cookies', { domain: 'foo' }, (resp) => {
expect(resp.error.message).to.eq('Could not process \'get:cookies\'. No automation clients connected.')
return done()
})
})
it('returns early if disconnect event is from another browser', function (done) {
const delaySpy = sinon.spy(Promise, 'delay')
this.extClient.on('disconnect', () => {
expect(delaySpy).to.not.have.been.calledWith(2000)
return done()
})
ctx.coreData.activeBrowser = { path: 'path-to-browser-two' }
this.extClient.disconnect()
})
it('returns true when tab matches magic string', function (done) {
const code = 'var s; (s = document.getElementById(\'__cypress-string\')) && s.textContent'
sinon.stub(chrome.tabs, 'query')
.withArgs({ windowType: 'normal' })
.yieldsAsync([{ id: 1, url: 'http://localhost' }])
sinon.stub(chrome.tabs, 'executeScript')
.withArgs(1, { code })
.yieldsAsync(['string'])
return this.client.emit('is:automation:client:connected', { element: '__cypress-string', randomString: 'string' }, (resp) => {
expect(resp).to.be.true
return done()
})
})
it('returns true after retrying', function (done) {
sinon.stub(extensionBackgroundPage, 'query').resolves(true)
// just force isSocketConnected to return false until the 4th retry
const iSC = sinon.stub(this.socket, 'isSocketConnected')
iSC
.onCall(0).returns(false)
.onCall(1).returns(false)
.onCall(2).returns(false)
.onCall(3).returns(true)
// oA.resolves(true)
return this.client.emit('is:automation:client:connected', { element: '__cypress-string', randomString: 'string' }, (resp) => {
expect(iSC.callCount).to.eq(4)
// expect(oA.callCount).to.eq(1)
expect(resp).to.be.true
return done()
})
})
it('returns false when times out', function (done) {
const code = 'var s; (s = document.getElementById(\'__cypress-string\')) && s.textContent'
sinon.stub(chrome.tabs, 'query')
.withArgs({ url: 'CHANGE_ME_HOST/*', windowType: 'normal' })
.yieldsAsync([{ id: 1 }])
sinon.stub(chrome.tabs, 'executeScript')
.withArgs(1, { code })
.yieldsAsync(['foobarbaz'])
// reduce the timeout so we dont have to wait so long
return this.client.emit('is:automation:client:connected', { element: '__cypress-string', randomString: 'string', timeout: 100 }, (resp) => {
expect(resp).to.be.false
return done()
})
})
it('retries multiple times and stops after timing out', function (done) {
// just force isSocketConnected to return false until the 4th retry
const iSC = sinon.stub(this.socket, 'isSocketConnected')
// reduce the timeout so we dont have to wait so long
return this.client.emit('is:automation:client:connected', { element: '__cypress-string', randomString: 'string', timeout: 200 }, (resp) => {
const {
callCount,
} = iSC
// it retries every 25ms so explect that
// this function was called at least 2 times
expect(callCount).to.be.gt(2)
expect(resp).to.be.false
return _.delay(() => {
// wait another 100ms and make sure
// that it was canceled and not continuously
// retried!
// if we remove Promise.config({cancellation: true})
// then this will fail. bluebird has changed its
// cancelation logic before and so we want to use
// an integration test to ensure this works as expected
expect(callCount).to.eq(iSC.callCount)
return done()
}
, 1000)
})
})
})
describe('options.onAutomationRequest', () => {
beforeEach(function () {
this.ar = sinon.stub(this.automation, 'request')
})
it('calls onAutomationRequest with message and data', function (done) {
this.ar.withArgs('focus', { foo: 'bar' }).resolves([])
return this.client.emit('automation:request', 'focus', { foo: 'bar' }, (resp) => {
expect(resp).to.deep.eq({ response: [] })
return done()
})
})
it('calls callback with error on rejection', function (done) {
const error = new Error('foo')
this.ar.withArgs('focus', { foo: 'bar' }).rejects(error)
return this.client.emit('automation:request', 'focus', { foo: 'bar' }, (resp) => {
expect(resp.error.message).to.deep.eq(error.message)
return done()
})
})
it('does not return __cypress or __socket namespaced cookies', () => {})
it('throws when onAutomationRequest rejects')
it('is:automation:client:connected returns true', function (done) {
this.ar.withArgs('is:automation:client:connected', { randomString: 'foo' }).resolves(true)
return this.client.emit('is:automation:client:connected', { randomString: 'foo' }, (resp) => {
expect(resp).to.be.true
return done()
})
})
})
})
context('on(automation:push:request)', () => {
beforeEach(function (done) {
this.socketClient.on('automation:client:connected', () => {