mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-03 21:40:28 -05:00
chore: cut over web extension methods to use webdriver BiDi to automate cookie behavior similar to CDP client (#31209)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user