From f76f89d4f1af4682b0fb5acf23567e1461f19175 Mon Sep 17 00:00:00 2001 From: AtofStryker Date: Tue, 22 Oct 2024 21:51:46 -0400 Subject: [PATCH] chore: cut over web extension methods to use webdriver BiDi to automate cookie behavior similar to CDP client --- packages/extension/app/v2/background.js | 229 --- .../test/integration/v2/background_spec.js | 601 ------ packages/server/lib/automation/util.ts | 10 +- .../server/lib/browsers/bidi_automation.ts | 368 +++- packages/server/lib/browsers/firefox-util.ts | 6 +- packages/server/lib/browsers/firefox.ts | 3 - .../unit/browsers/bidi_automation_spec.ts | 1640 ++++++++++++++++- .../server/test/unit/browsers/firefox_spec.ts | 1 + packages/server/test/unit/socket_spec.js | 282 --- 9 files changed, 2012 insertions(+), 1128 deletions(-) diff --git a/packages/extension/app/v2/background.js b/packages/extension/app/v2/background.js index 5a3bb0ca17c3..c7d91c5acc1c 100644 --- a/packages/extension/app/v2/background.js +++ b/packages/extension/app/v2/background.js @@ -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 diff --git a/packages/extension/test/integration/v2/background_spec.js b/packages/extension/test/integration/v2/background_spec.js index 1cd0e3591a3a..eef57bda3771 100644 --- a/packages/extension/test/integration/v2/background_spec.js +++ b/packages/extension/test/integration/v2/background_spec.js @@ -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') - }) - }) }) }) diff --git a/packages/server/lib/automation/util.ts b/packages/server/lib/automation/util.ts index baf04a9ec320..5d45fab507a5 100644 --- a/packages/server/lib/automation/util.ts +++ b/packages/server/lib/automation/util.ts @@ -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 & { @@ -12,16 +12,16 @@ export type CyCookie = Pick { - 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 } diff --git a/packages/server/lib/browsers/bidi_automation.ts b/packages/server/lib/browsers/bidi_automation.ts index b9329cefe182..3440f323dc81 100644 --- a/packages/server/lib/browsers/bidi_automation.ts +++ b/packages/server/lib/browsers/bidi_automation.ts @@ -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' + +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 { + name: string + value: { + type: 'string' + value: string + } + domain: string + path: string + httpOnly: boolean + hostOnly?: boolean + secure: boolean + sameSite: NetworkSameSite + expiry?: number +} -import { bidiKeyPress } from '../automation/commands/key_press' - -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) 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) diff --git a/packages/server/lib/browsers/firefox-util.ts b/packages/server/lib/browsers/firefox-util.ts index 9d093e33945e..e888bd9a4459 100644 --- a/packages/server/lib/browsers/firefox-util.ts +++ b/packages/server/lib/browsers/firefox-util.ts @@ -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() diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index eb217309cc46..d805413b81f1 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -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 () { diff --git a/packages/server/test/unit/browsers/bidi_automation_spec.ts b/packages/server/test/unit/browsers/bidi_automation_spec.ts index 4f2575df9f69..dab7d8d4eddd 100644 --- a/packages/server/test/unit/browsers/bidi_automation_spec.ts +++ b/packages/server/test/unit/browsers/bidi_automation_spec.ts @@ -1,7 +1,9 @@ import EventEmitter from 'node:events' -import { BidiAutomation } from '../../../lib/browsers/bidi_automation' - import type { Client as WebDriverClient } from 'webdriver' +import { expect } from 'chai' +import sinon from 'sinon' +import { toInteger } from 'lodash' +import { BidiAutomation } from '../../../lib/browsers/bidi_automation' import type { NetworkBeforeRequestSentParametersModified } from '../../../lib/browsers/bidi_automation' import type { Automation } from '../../../lib/automation' import type { NetworkFetchErrorParameters, NetworkResponseCompletedParameters, NetworkResponseStartedParameters } from 'webdriver/build/bidi/localTypes' @@ -536,5 +538,1639 @@ describe('lib/browsers/bidi_automation', () => { }) }) }) + + describe('onRequest', () => { + let bidiAutomationInstance: BidiAutomation + + beforeEach(() => { + bidiAutomationInstance = BidiAutomation.create(mockWebdriverClient, mockAutomationClient) + }) + + describe('Cookies', () => { + // important to note that the filter that gets passed into the onRequest for get:cookies is actually + // a cookie-like object + describe('get:cookies', () => { + describe('returns cookies that match filter via', () => { + it('data.url / domain', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + // this cookie should be filtered out + domain: '.www.barbaz.com', + expiry: 123456789, + httpOnly: false, + name: 'key2', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value2', + }, + }], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', { + url: 'http://www.foobar.com:3500/index.html', + }) + + expect(cookies).to.deep.equal([{ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }]) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: { + // this would filter out secure cookies and prevent sending them in a secure context + secure: false, + } }) + }) + + it('data.url / path', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + domain: '.app.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key2', + path: '/foo', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value2', + }, + }, + { + domain: '.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key3', + path: '/foo/bar', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value3', + }, + }, + { + // this cookie should be filtered out + domain: 'www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key4', + path: '/baz', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value4', + }, + }], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', { + url: 'http://app.www.foobar.com:3500/foo/bar/index.html', + }) + + expect(cookies).to.deep.equal([{ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }, { + domain: '.app.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key2', + path: '/foo', + sameSite: 'lax', + secure: false, + value: 'value2', + }, { + domain: '.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key3', + path: '/foo/bar', + sameSite: 'lax', + secure: false, + value: 'value3', + }]) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: { + // this would filter out secure cookies and prevent sending them in a secure context + secure: false, + } }) + }) + + it('cookie name', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + domain: '.www.barbaz.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', { + name: 'key1', + }) + + expect(cookies).to.deep.equal([{ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }, { + domain: '.www.barbaz.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }]) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ + filter: { + name: 'key1', + }, + }) + }) + + it('cookie path', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + domain: '.www.barbaz.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', { + path: '/', + }) + + expect(cookies).to.deep.equal([{ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }, { + domain: '.www.barbaz.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }]) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + }) + + describe('domain hierarchy', () => { + it('returns superdomain related cookies (ex: foobar.com is a super domain of www.foobar.com', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + domain: '.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key2', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value2', + }, + }], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', { + url: 'https://www.foobar.com', + }) + + expect(cookies).to.deep.equal([{ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }, { + domain: '.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key2', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value2', + }]) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + + it('does NOT return subdomain cookies (ex: www.foobar.com is a sub domain of foobar.com', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + // this cookie should be filtered out + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + domain: '.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key2', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value2', + }, + }], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', { + url: 'https://foobar.com', + }) + + expect(cookies).to.deep.equal([{ + domain: '.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key2', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value2', + }]) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + }) + + it('returns no cookies if no match on the filter', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', undefined) + + expect(cookies).to.deep.equal([]) + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + + it('returns all cookies if there is no filter', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + domain: '.www.barbaz.com', + expiry: 123456789, + httpOnly: false, + name: 'key2', + path: '/foo', + sameSite: 'strict', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value2', + }, + }], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', {}) + + expect(cookies).to.deep.equal([{ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }, { + domain: '.www.barbaz.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key2', + path: '/foo', + sameSite: 'strict', + secure: false, + value: 'value2', + }]) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + + // TODO: do we try/catch this and return an empty array and log the error? + it('Throws error if for some reason fetching cookies fails', async () => { + const mockError = new Error('fetching cookies failed!') + + mockWebdriverClient.storageGetCookies = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('get:cookies', {})).to.be.rejectedWith(mockError) + }) + }) + + describe('get:cookie', () => { + describe('returns cookies that match filter via', () => { + it('cookie name', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookie', { + name: 'key1', + }) + + expect(cookie).to.deep.equal({ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/', + sameSite: 'lax', + secure: false, + value: 'value1', + }) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ + filter: { + name: 'key1', + }, + }) + }) + + it('cookie path', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/foobar', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookie', { + path: '/foobar', + }) + + expect(cookie).to.deep.equal({ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/foobar', + sameSite: 'lax', + secure: false, + value: 'value1', + }) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + }) + + it('returns the first matching cookie', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key1', + path: '/foobar', + sameSite: 'lax', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value1', + }, + }, { + domain: '.www.foobar.com', + expiry: 123456789, + httpOnly: false, + name: 'key2', + path: '/foobar', + sameSite: 'strict', + secure: false, + size: 10, + value: { + type: 'string', + value: 'value2', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookie', { + path: '/foobar', + }) + + expect(cookie).to.deep.equal({ + domain: '.www.foobar.com', + expirationDate: 123456789, + httpOnly: false, + hostOnly: false, + name: 'key1', + path: '/foobar', + sameSite: 'lax', + secure: false, + value: 'value1', + }) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + + it('returns null if no cookie is found', async () => { + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [], + }) + + const cookies = await bidiAutomationInstance.automationMiddleware.onRequest('get:cookie', {}) + + expect(cookies).to.equal(null) + + expect(mockWebdriverClient.storageGetCookies).to.have.been.calledWith({ filter: {} }) + }) + + // TODO: do we try/catch this and return an empty array and log the error? + it('Throws error if for some reason fetching cookies fails', async () => { + const mockError = new Error('fetching cookies failed!') + + mockWebdriverClient.storageGetCookies = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('get:cookie', {})).to.be.rejectedWith(mockError) + }) + }) + + describe('set:cookie', () => { + it('sets a single cookie', async () => { + const cyCookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1234567890.123, + } + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.foobar.com', + expiry: 1234567890, + httpOnly: true, + name: 'testCookie', + path: '/', + sameSite: 'lax', + secure: true, + size: 10, + value: { + type: 'string', + value: 'testValue', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie', + value: { type: 'string', value: 'testValue' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + expiry: 1234567890, + }, + }) + + expect(cookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + sameSite: 'lax', + expirationDate: 1234567890, + }) + }) + + it('throws an error if setting a cookie fails', async () => { + const cookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1234567890, + } + + const mockError = new Error('setting cookie failed!') + + mockWebdriverClient.storageSetCookie = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cookie)).to.be.rejectedWith(mockError) + }) + + describe('parsing', () => { + // NOTE: unique to Firefox. Chromium defaults to 'lax' + it('defaults sameSite to "none"', async () => { + const cyCookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + } + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.foobar.com', + httpOnly: true, + expiry: undefined, + name: 'testCookie', + path: '/', + sameSite: 'none', + secure: true, + size: 10, + value: { + type: 'string', + value: 'testValue', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie', + value: { type: 'string', value: 'testValue' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'none', + expiry: undefined, + }, + }) + + expect(cookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + sameSite: 'no_restriction', + expirationDate: undefined, + }) + }) + + it('parses a -Infinity expiry as 0', async () => { + const cyCookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + sameSite: 'strict', + httpOnly: true, + expirationDate: -Infinity, + } + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.foobar.com', + httpOnly: true, + expiry: 0, + name: 'testCookie', + path: '/', + sameSite: 'strict', + secure: true, + size: 10, + value: { + type: 'string', + value: 'testValue', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie', + value: { type: 'string', value: 'testValue' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expiry: 0, + }, + }) + + expect(cookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + sameSite: 'strict', + expirationDate: 0, + }) + }) + + it('parses a float expiry to an integer', async () => { + const cyCookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + sameSite: 'strict', + httpOnly: true, + expirationDate: 12345.67894, + } + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.foobar.com', + httpOnly: true, + expiry: 12345, + name: 'testCookie', + path: '/', + sameSite: 'strict', + secure: true, + size: 10, + value: { + type: 'string', + value: 'testValue', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie', + value: { type: 'string', value: 'testValue' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expiry: 12345, + }, + }) + + expect(cookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + sameSite: 'strict', + expirationDate: 12345, + }) + }) + + it('parses an Infinity expiry as undefined', async () => { + const cyCookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + sameSite: 'strict', + httpOnly: true, + expirationDate: Infinity, + } + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.foobar.com', + httpOnly: true, + expiry: toInteger(Infinity), + name: 'testCookie', + path: '/', + sameSite: 'strict', + secure: true, + size: 10, + value: { + type: 'string', + value: 'testValue', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie', + value: { type: 'string', value: 'testValue' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expiry: toInteger(Infinity), + }, + }) + + expect(cookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + sameSite: 'strict', + expirationDate: toInteger(Infinity), + }) + }) + + it('parses other expiry as undefined', async () => { + const cyCookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + sameSite: 'strict', + httpOnly: true, + expirationDate: null, + } + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.foobar.com', + httpOnly: true, + expiry: undefined, + name: 'testCookie', + path: '/', + sameSite: 'strict', + secure: true, + size: 10, + value: { + type: 'string', + value: 'testValue', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie', + value: { type: 'string', value: 'testValue' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expiry: undefined, + }, + }) + + expect(cookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + sameSite: 'strict', + expirationDate: undefined, + }) + }) + + it('sets a single cookie', async () => { + const cyCookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1234567890.123, + } + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + domain: '.foobar.com', + expiry: 1234567890, + httpOnly: true, + name: 'testCookie', + path: '/', + sameSite: 'lax', + secure: true, + size: 10, + value: { + type: 'string', + value: 'testValue', + }, + }], + }) + + const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie', + value: { type: 'string', value: 'testValue' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + expiry: 1234567890, + }, + }) + + expect(cookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + sameSite: 'lax', + expirationDate: 1234567890, + }) + }) + }) + }) + + describe('add:cookies', () => { + it('adds multiple cookies', async () => { + const cookies = [ + { + name: 'testCookie1', + value: 'testValue1', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1234567890, + }, + { + name: 'testCookie2', + value: 'testValue2', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'strict', + expirationDate: 1234567891, + }, + ] + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + const returnValue = await bidiAutomationInstance.automationMiddleware.onRequest('add:cookies', cookies) + + expect(returnValue).to.be.undefined + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie1', + value: { type: 'string', value: 'testValue1' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + expiry: 1234567890, + }, + }) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie2', + value: { type: 'string', value: 'testValue2' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expiry: 1234567891, + }, + }) + }) + + it('throws an error if setting any cookie fails', async () => { + const cookies = [ + { + name: 'testCookie1', + value: 'testValue1', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1234567890, + }, + { + name: 'testCookie2', + value: 'testValue2', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'strict', + expirationDate: 1234567891, + }, + ] + + const mockError = new Error('adding cookies failed!') + + mockWebdriverClient.storageSetCookie = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('add:cookies', cookies)).to.be.rejectedWith(mockError) + }) + }) + + describe('set:cookies', () => { + it('sets multiple cookies', async () => { + const cookies = [ + { + name: 'testCookie1', + value: 'testValue1', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1234567890, + }, + { + name: 'testCookie2', + value: 'testValue2', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'strict', + expirationDate: 1234567891, + }, + ] + + mockWebdriverClient.storageDeleteCookies = sinon.stub().resolves() + + mockWebdriverClient.storageSetCookie = sinon.stub().resolves() + + const returnValue = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookies', cookies) + + expect(returnValue).to.be.undefined + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie1', + value: { type: 'string', value: 'testValue1' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + expiry: 1234567890, + }, + }) + + expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ + cookie: { + name: 'testCookie2', + value: { type: 'string', value: 'testValue2' }, + domain: '.foobar.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expiry: 1234567891, + }, + }) + + // deletes all cookies before adding new ones, which is the main difference between set:cookies and add:cookies + expect(mockWebdriverClient.storageDeleteCookies).to.have.been.calledWith({}) + }) + + it('throws an error if setting any cookie fails', async () => { + const cookies = [ + { + name: 'testCookie1', + value: 'testValue1', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1234567890, + }, + { + name: 'testCookie2', + value: 'testValue2', + domain: '.foobar.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'strict', + expirationDate: 1234567891, + }, + ] + + const mockError = new Error('setting cookie failed!') + + mockWebdriverClient.storageDeleteCookies = sinon.stub().resolves() + + mockWebdriverClient.storageSetCookie = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('set:cookies', cookies)).to.be.rejectedWith(mockError) + + expect(mockWebdriverClient.storageDeleteCookies).to.have.been.calledWith({}) + }) + }) + + describe('clear:cookie', () => { + it('clears a single cookie and returns it\s value', async () => { + const cookieToClear = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'no_restriction', + } + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + name: 'testCookie', + value: { + type: 'string', + value: 'testValue', + }, + expiry: 1234567890, + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'none', + }], + }) + + mockWebdriverClient.storageDeleteCookies = sinon.stub().resolves() + + const clearedCookie = await bidiAutomationInstance.automationMiddleware.onRequest('clear:cookie', cookieToClear) + + expect(mockWebdriverClient.storageDeleteCookies).to.have.been.calledWith({ + filter: { + name: 'testCookie', + value: { + type: 'string', + value: 'testValue', + }, + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'none', + }, + }) + + expect(clearedCookie).to.deep.equal({ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + expirationDate: 1234567890, + path: '/', + httpOnly: false, + hostOnly: false, + secure: false, + sameSite: 'no_restriction', + }) + }) + + it('returns undefined if the cookie does not exist', async () => { + const cookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'no_restriction', + } + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [], + }) + + const result = await bidiAutomationInstance.automationMiddleware.onRequest('clear:cookie', cookie) + + expect(result).to.be.undefined + }) + + it('throws an error if clearing a cookie fails', async () => { + const cookie = { + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'no_restriction', + } + + const mockError = new Error('clearing cookie failed!') + + mockWebdriverClient.storageGetCookies = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('clear:cookie', cookie)).to.be.rejectedWith(mockError) + }) + }) + + describe('clear:cookies', () => { + it('clears a single cookie and returns it\s value', async () => { + const cookiesToClear = [{ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'no_restriction', + }, { + name: 'testCookie2', + value: 'testValue2', + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'lax', + }] + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [{ + name: 'testCookie', + value: { + type: 'string', + value: 'testValue', + }, + expiry: 1234567890, + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'none', + }, { + name: 'testCookie2', + value: { + type: 'string', + value: 'testValue2', + }, + expiry: 1234567890, + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'lax', + }], + }) + + mockWebdriverClient.storageDeleteCookies = sinon.stub().resolves() + + const clearedCookie = await bidiAutomationInstance.automationMiddleware.onRequest('clear:cookies', cookiesToClear) + + expect(mockWebdriverClient.storageDeleteCookies).to.have.been.calledWith({ + filter: { + name: 'testCookie', + value: { + type: 'string', + value: 'testValue', + }, + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'none', + }, + }) + + expect(mockWebdriverClient.storageDeleteCookies).to.have.been.calledWith({ + filter: { + + name: 'testCookie2', + value: { + type: 'string', + value: 'testValue2', + }, + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'lax', + + }, + }) + + expect(clearedCookie).to.deep.equal([{ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + expirationDate: 1234567890, + path: '/', + httpOnly: false, + hostOnly: false, + secure: false, + sameSite: 'no_restriction', + }, + { + name: 'testCookie2', + value: 'testValue2', + domain: '.foobar.com', + expirationDate: 1234567890, + path: '/', + httpOnly: false, + hostOnly: false, + secure: false, + sameSite: 'lax', + }]) + }) + + it('returns undefined if the cookie does not exist', async () => { + const cookies = [{ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'no_restriction', + }] + + mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ + cookies: [], + }) + + const result = await bidiAutomationInstance.automationMiddleware.onRequest('clear:cookies', cookies) + + expect(result).to.deep.equal([]) + }) + + it('throws an error if clearing a cookie fails', async () => { + const cookies = [{ + name: 'testCookie', + value: 'testValue', + domain: '.foobar.com', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'no_restriction', + }] + + const mockError = new Error('clearing cookies failed!') + + mockWebdriverClient.storageGetCookies = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('clear:cookies', cookies)).to.be.rejectedWith(mockError) + }) + }) + }) + + it('returns "true" when "is:automation:client:connected"', async () => { + const isAutomationClientConnected = await bidiAutomationInstance.automationMiddleware.onRequest('is:automation:client:connected', undefined) + + expect(isAutomationClientConnected).to.be.true + }) + + describe('take:screenshot', () => { + it('successfully takes a screenshot', async () => { + mockWebdriverClient.browsingContextGetTree = sinon.stub().resolves({ + contexts: [{ context: '123' }], + }) + + mockWebdriverClient.browsingContextActivate = sinon.stub().resolves() + mockWebdriverClient.browsingContextCaptureScreenshot = sinon.stub().resolves({ + data: 'iVBORw0KGgoAAAANSUhEUgAAAAUA', + }) + + const screenshot = await bidiAutomationInstance.automationMiddleware.onRequest('take:screenshot', {}) + + expect(screenshot).to.equal('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA') + expect(mockWebdriverClient.browsingContextGetTree).to.have.been.calledWith({}) + expect(mockWebdriverClient.browsingContextActivate).to.have.been.calledWith({ + context: '123', + }) + + expect(mockWebdriverClient.browsingContextCaptureScreenshot).to.have.been.calledWith({ + context: '123', + format: { + type: 'png', + } }) + }) + + it('throws an error if taking a screenshot fails', async () => { + const mockError = new Error('taking screenshot failed!') + + mockWebdriverClient.browsingContextGetTree = sinon.stub().resolves({ + contexts: [{ context: '123' }], + }) + + mockWebdriverClient.browsingContextActivate = sinon.stub().resolves() + mockWebdriverClient.browsingContextCaptureScreenshot = sinon.stub().rejects(mockError) + + expect(bidiAutomationInstance.automationMiddleware.onRequest('take:screenshot', {})).to.be.rejectedWith(mockError) + }) + }) + + it('throws a AutomationNotImplemented error when "reset:browser:state" is emitted to inform the default automation client (web extension) to handle it', async () => { + expect(bidiAutomationInstance.automationMiddleware.onRequest('reset:browser:state')).to.be.rejectedWith(`Automation command 'reset:browser:state' not implemented by BiDiAutomation`) + }) + + describe('reset:browser:tabs:for:next:spec', () => { + it('successfully recreates the test tab (shouldKeepTabOpen=true) closes all other tabs', async () => { + mockWebdriverClient.browsingContextGetTree = sinon.stub().resolves({ + contexts: [{ context: '123' }], + }) + + mockWebdriverClient.browsingContextCreate = sinon.stub().resolves({ + context: '456', + }) + + mockWebdriverClient.browsingContextClose = sinon.stub().resolves() + + const returnValue = await bidiAutomationInstance.automationMiddleware.onRequest('reset:browser:tabs:for:next:spec', { + shouldKeepTabOpen: true, + }) + + expect(returnValue).to.be.undefined + expect(mockWebdriverClient.browsingContextGetTree).to.have.been.calledWith({}) + expect(mockWebdriverClient.browsingContextCreate).to.have.been.calledWith({ + type: 'tab', + }) + + expect(mockWebdriverClient.browsingContextClose).to.have.been.calledWith({ + context: '123', + }) + }) + + it('successfully closes all tabs (shouldKeepTabOpen=false)', async () => { + mockWebdriverClient.browsingContextGetTree = sinon.stub().resolves({ + contexts: [{ context: '123' }], + }) + + mockWebdriverClient.browsingContextCreate = sinon.stub().resolves() + + mockWebdriverClient.browsingContextClose = sinon.stub().resolves() + + const returnValue = await bidiAutomationInstance.automationMiddleware.onRequest('reset:browser:tabs:for:next:spec', { + shouldKeepTabOpen: false, + }) + + expect(returnValue).to.be.undefined + expect(mockWebdriverClient.browsingContextGetTree).to.have.been.calledWith({}) + expect(mockWebdriverClient.browsingContextCreate).to.have.not.been.called + + expect(mockWebdriverClient.browsingContextClose).to.have.been.calledWith({ + context: '123', + }) + }) + }) + + describe('focus:browser:window', () => { + // TODO: might need to rewrite this test and just pass in the AUT context id that exists in the class + it('focuses the browser window (AUT should be first window)', async () => { + mockWebdriverClient.browsingContextGetTree = sinon.stub().resolves({ + contexts: [{ context: '123' }], + }) + + mockWebdriverClient.browsingContextActivate = sinon.stub().resolves() + + const returnValue = await bidiAutomationInstance.automationMiddleware.onRequest('focus:browser:window', {}) + + expect(returnValue).to.be.undefined + expect(mockWebdriverClient.browsingContextGetTree).to.have.been.calledWith({}) + expect(mockWebdriverClient.browsingContextActivate).to.have.been.calledWith({ + context: '123', + }) + }) + }) + + it('throws an error if an event passed in does not exist', () => { + // @ts-expect-error + expect(bidiAutomationInstance.automationMiddleware.onRequest('foo:bar:baz', {})).to.be.rejectedWith('Automation command \'foo:bar:baz\' not implemented by BiDiAutomation') + }) + }) }) }) diff --git a/packages/server/test/unit/browsers/firefox_spec.ts b/packages/server/test/unit/browsers/firefox_spec.ts index 502a638bc3c5..8b5b16098b1d 100644 --- a/packages/server/test/unit/browsers/firefox_spec.ts +++ b/packages/server/test/unit/browsers/firefox_spec.ts @@ -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) }) diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index 9b1ad33b48ad..ef3e56eccb27 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -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', () => {