From cb14ae62e7c4362d759ceb450f4af518ee486f90 Mon Sep 17 00:00:00 2001 From: Emily Rohrbough Date: Tue, 3 May 2022 14:01:03 -0500 Subject: [PATCH] chore(sessions): break out sessions manager code and add unit tests (#21268) Co-authored-by: Chris Breiding Co-authored-by: Bill Glesias --- .../commands/sessions/manager_spec.ts | 355 ++++++++++++++++++ .../driver/src/cy/commands/sessions/index.ts | 335 ++--------------- .../src/cy/commands/sessions/manager.ts | 295 +++++++++++++++ 3 files changed, 677 insertions(+), 308 deletions(-) create mode 100644 packages/driver/cypress/integration/commands/sessions/manager_spec.ts create mode 100644 packages/driver/src/cy/commands/sessions/manager.ts diff --git a/packages/driver/cypress/integration/commands/sessions/manager_spec.ts b/packages/driver/cypress/integration/commands/sessions/manager_spec.ts new file mode 100644 index 000000000000..be10eb7625df --- /dev/null +++ b/packages/driver/cypress/integration/commands/sessions/manager_spec.ts @@ -0,0 +1,355 @@ +const SessionsManager = require('../../../../src/cy/commands/sessions/manager').default +const $Cypress = require('../../../../src/cypress').default + +describe('src/cy/commands/sessions/manager.ts', () => { + let CypressInstance + let baseUrl + + beforeEach(function () { + // @ts-ignore + CypressInstance = new $Cypress() + baseUrl = Cypress.config('baseUrl') + }) + + it('creates SessionsManager instance', () => { + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + + expect(sessionsManager).to.haveOwnProperty('cy') + expect(sessionsManager).to.haveOwnProperty('Cypress') + expect(sessionsManager).to.haveOwnProperty('currentTestRegisteredSessions') + expect(sessionsManager.currentTestRegisteredSessions).to.be.instanceOf(Map) + }) + + describe('.setActiveSession()', () => { + it('adds session when none were previously added', () => { + const cySpy = cy.spy(cy, 'state').withArgs('activeSessions') + + const activeSession: Cypress.Commands.Session.ActiveSessions = { + 'session_1': { + id: 'session_1', + setup: () => {}, + hydrated: true, + }, + } + + const sessionsManager = new SessionsManager(CypressInstance, cy) + + sessionsManager.setActiveSession(activeSession) + const calls = cySpy.getCalls() + + expect(cySpy).to.be.calledTwice + expect(calls[0].args[1]).to.be.undefined + expect(calls[1].args[1]).to.haveOwnProperty('session_1') + }) + + it('adds session when other sessions were previously added', () => { + const existingSessions: Cypress.Commands.Session.ActiveSessions = { + 'session_1': { + id: 'session_1', + setup: () => {}, + hydrated: false, + }, + 'session_2': { + id: 'session_2', + setup: () => {}, + hydrated: true, + }, + } + + const cySpy = cy.stub(cy, 'state').callThrough().withArgs('activeSessions').returns(existingSessions) + + const activeSession: Cypress.Commands.Session.ActiveSessions = { + 'session_3': { + id: 'session_3', + setup: () => {}, + hydrated: true, + }, + } + + const sessionsManager = new SessionsManager(CypressInstance, cy) + + sessionsManager.setActiveSession(activeSession) + const calls = cySpy.getCalls() + + expect(cySpy).to.be.calledTwice + expect(calls[0].args[1]).to.be.undefined + expect(calls[1].args[1]).to.haveOwnProperty('session_1') + expect(calls[1].args[1]).to.haveOwnProperty('session_2') + expect(calls[1].args[1]).to.haveOwnProperty('session_3') + }) + }) + + describe('.getActiveSession()', () => { + it('returns undefined when no active sessions', () => { + const cySpy = cy.stub(cy, 'state').callThrough().withArgs('activeSessions') + + const sessionsManager = new SessionsManager(CypressInstance, cy) + + const activeSession = sessionsManager.getActiveSession('session_1') + + expect(cySpy).to.be.calledOnce + expect(activeSession).to.be.undefined + }) + + it('returns session when found', () => { + const activeSessions: Cypress.Commands.Session.ActiveSessions = { + 'session_1': { + id: 'session_1', + setup: () => {}, + hydrated: false, + }, + 'session_2': { + id: 'session_2', + setup: () => {}, + hydrated: true, + }, + } + + const cySpy = cy.stub(cy, 'state').callThrough().withArgs('activeSessions').returns(activeSessions) + + const sessionsManager = new SessionsManager(CypressInstance, cy) + + let activeSession = sessionsManager.getActiveSession('session_1') + + expect(cySpy).to.be.calledOnce + expect(activeSession).to.deep.eq(activeSessions['session_1']) + }) + }) + + describe('.clearActiveSessions()', () => { + it('handles when no active sessions have been set', () => { + const cySpy = cy.stub(cy, 'state').callThrough().withArgs('activeSessions') + + const sessionsManager = new SessionsManager(CypressInstance, cy) + + sessionsManager.clearActiveSessions() + const calls = cySpy.getCalls() + + expect(cySpy).to.be.calledTwice + expect(calls[1].args[1]).to.be.instanceOf(Object) + expect(calls[1].args[1]).to.deep.eq({}) + }) + + it('updates the existing active sessions to "hydrated: false"', () => { + const existingSessions: Cypress.Commands.Session.ActiveSessions = { + 'session_1': { + id: 'session_1', + setup: () => {}, + hydrated: false, + }, + 'session_2': { + id: 'session_2', + setup: () => {}, + hydrated: true, + }, + } + + const cySpy = cy.stub(cy, 'state').callThrough().withArgs('activeSessions').returns(existingSessions) + + const sessionsManager = new SessionsManager(CypressInstance, cy) + + sessionsManager.clearActiveSessions() + const calls = cySpy.getCalls() + + expect(cySpy).to.be.calledTwice + expect(calls[1].args[1]).to.be.instanceOf(Object) + expect(calls[1].args[1]).to.haveOwnProperty('session_1') + expect(calls[1].args[1].session_1).to.haveOwnProperty('hydrated', false) + expect(calls[1].args[1]).to.haveOwnProperty('session_2') + expect(calls[1].args[1].session_2).to.haveOwnProperty('hydrated', false) + }) + }) + + describe('.mapOrigins()', () => { + it('maps when requesting all origins', async () => { + const sessionsManager = new SessionsManager(CypressInstance, cy) + + const allOrigins = ['https://example.com', baseUrl, 'http://foobar.com', 'http://foobar.com'] + const sessionsSpy = cy.stub(sessionsManager, 'getAllHtmlOrigins').resolves(allOrigins) + + const origins = await sessionsManager.mapOrigins('*') + + expect(origins).to.deep.eq(['https://example.com', baseUrl, 'http://foobar.com']) + expect(sessionsSpy).to.be.calledOnce + }) + + it('maps when requesting the current origin', async () => { + const sessionsManager = new SessionsManager(CypressInstance, cy) + const sessionsSpy = cy.stub(sessionsManager, 'getAllHtmlOrigins') + const origins = await sessionsManager.mapOrigins('currentOrigin') + + expect(origins).to.deep.eq([baseUrl]) + expect(sessionsSpy).not.to.be.called + }) + + it('maps when requesting a specific origin', async () => { + const sessionsManager = new SessionsManager(CypressInstance, cy) + const sessionsSpy = cy.stub(sessionsManager, 'getAllHtmlOrigins') + const origins = await sessionsManager.mapOrigins('https://example.com/random_page?1') + + expect(origins).to.deep.eq(['https://example.com']) + expect(sessionsSpy).not.to.be.called + }) + + it('maps when requesting a list of origins', async () => { + const sessionsManager = new SessionsManager(CypressInstance, cy) + + const allOrigins = ['https://example.com', baseUrl, 'http://foobar.com', 'http://foobar.com'] + const sessionsSpy = cy.stub(sessionsManager, 'getAllHtmlOrigins').resolves(allOrigins) + + const origins = await sessionsManager.mapOrigins(['*', 'https://other.com']) + + expect(origins).to.deep.eq(['https://example.com', baseUrl, 'http://foobar.com', 'https://other.com']) + expect(sessionsSpy).to.be.calledOnce + }) + }) + + // TODO: + describe('._setStorageOnOrigins()', () => {}) + + it('.getAllHtmlOrigins()', async () => { + const storedOrigins = { + 'https://example.com': {}, + 'https://foobar.com': {}, + } + + storedOrigins[`${baseUrl}`] = {} + const cypressSpy = cy.stub(CypressInstance, 'backend').callThrough().withArgs('get:rendered:html:origins').resolves(storedOrigins) + const sessionsManager = new SessionsManager(CypressInstance, cy) + + const origins = await sessionsManager.getAllHtmlOrigins() + + expect(cypressSpy).have.been.calledOnce + expect(origins).to.have.lengthOf(3) + expect(origins).to.deep.eq(['https://example.com', 'https://foobar.com', baseUrl]) + }) + + describe('.sessions', () => { + it('sessions.defineSession()', () => { + const sessionsManager = new SessionsManager(CypressInstance, cy) + const sessionsSpy = cy.stub(sessionsManager, 'setActiveSession') + const setup = cy.stub() + const sess = sessionsManager.sessions.defineSession({ id: '1', setup }) + + expect(sess).to.deep.eq({ + id: '1', + setup, + validate: undefined, + cookies: null, + localStorage: null, + hydrated: false, + }) + + expect(sessionsSpy).to.be.calledOnce + expect(sessionsSpy.getCall(0).args[0]).to.deep.eq({ 1: sess }) + }) + + it('sessions.clearAllSavedSessions()', async () => { + const cypressSpy = cy.stub(CypressInstance, 'backend').withArgs('clear:session').resolves(null) + + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + const sessionsSpy = cy.stub(sessionsManager, 'clearActiveSessions') + + await sessionsManager.sessions.clearAllSavedSessions() + + expect(sessionsSpy).to.be.calledOnce + expect(cypressSpy).to.be.calledOnceWith('clear:session', null) + }) + + it('.clearCurrentSessionData()', async () => { + // Unable to cleanly mock localStorage or sessionStorage on Firefox, + // so add dummy values and ensure they are cleared as expected. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1141698 + window.localStorage.foo = 'bar' + window.sessionStorage.jazzy = 'music' + + expect(window.localStorage).of.have.lengthOf(1) + expect(window.sessionStorage).of.have.lengthOf(1) + + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + + const clearStorageSpy = cy.stub(sessionsManager.sessions, 'clearStorage') + const clearCookiesSpy = cy.stub(sessionsManager.sessions, 'clearCookies') + + await sessionsManager.sessions.clearCurrentSessionData() + + expect(clearStorageSpy).to.be.calledOnce + expect(clearCookiesSpy).to.be.calledOnce + expect(window.localStorage).of.have.lengthOf(0) + expect(window.sessionStorage).of.have.lengthOf(0) + }) + + // TODO: + describe('sessions.setSessionData', () => {}) + + it('sessions.getCookies()', async () => { + const cookies = [{ id: 'cookie' }] + const cypressSpy = cy.stub(CypressInstance, 'automation').withArgs('get:cookies').resolves(cookies) + + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + + const sessionCookies = await sessionsManager.sessions.getCookies() + + expect(cypressSpy).to.be.calledOnceWith('get:cookies', {}) + expect(sessionCookies).to.deep.eq(cookies) + }) + + it('sessions.setCookies()', async () => { + const cypressSpy = cy.stub(CypressInstance, 'automation').withArgs('set:cookies') + + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + + await sessionsManager.sessions.setCookies({}) + + expect(cypressSpy).to.be.calledOnceWith('set:cookies', {}) + }) + + it('sessions.clearCookies()', async () => { + const cookies = [{ id: 'cookie' }] + const cypressSpy = cy.stub(CypressInstance, 'automation').withArgs('clear:cookies').resolves([]) + + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + const sessionsSpy = cy.stub(sessionsManager.sessions, 'getCookies').resolves(cookies) + + await sessionsManager.sessions.clearCookies() + + expect(sessionsSpy).to.be.calledOnce + expect(cypressSpy).to.be.calledOnceWith('clear:cookies', cookies) + }) + + it('sessions.getCurrentSessionData', async () => { + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + const getStorageSpy = cy.stub(sessionsManager.sessions, 'getStorage').resolves({ localStorage: [] }) + const cookiesSpy = cy.stub(sessionsManager.sessions, 'getCookies').resolves([{ id: 'cookie' }]) + + const sessData = await sessionsManager.sessions.getCurrentSessionData() + + expect(sessData).to.deep.eq({ + localStorage: [], + cookies: [{ id: 'cookie' }], + }) + + expect(getStorageSpy).to.be.calledOnce + expect(cookiesSpy).to.be.calledOnce + }) + + it('sessions.getSession()', () => { + const cypressSpy = cy.stub(CypressInstance, 'backend').callThrough().withArgs('get:session') + + const sessionsManager = new SessionsManager(CypressInstance, () => {}) + + sessionsManager.sessions.getSession('session_1') + + expect(cypressSpy).to.be.calledOnceWith('get:session', 'session_1') + }) + + // TODO: + describe('sessions.getStorage', () => {}) + + // TODO: + describe('sessions.clearStorage', () => {}) + + // TODO: + describe('sessions.setStorage', () => {}) + }) +}) diff --git a/packages/driver/src/cy/commands/sessions/index.ts b/packages/driver/src/cy/commands/sessions/index.ts index b7268891eecc..ad998d43dd4b 100644 --- a/packages/driver/src/cy/commands/sessions/index.ts +++ b/packages/driver/src/cy/commands/sessions/index.ts @@ -1,337 +1,55 @@ import _ from 'lodash' -import { $Location } from '../../../cypress/location' -import $errUtils from '../../../cypress/error_utils' import stringifyStable from 'json-stable-stringify' +import $errUtils from '../../../cypress/error_utils' import $stackUtils from '../../../cypress/stack_utils' +import SessionsManager from './manager' import { getSessionDetails, - getCurrentOriginStorage, - setPostMessageLocalStorage, getConsoleProps, - getPostMessageLocalStorage, navigateAboutBlank, } from './utils' -const currentTestRegisteredSessions = new Map() -type ActiveSessions = Cypress.Commands.Session.ActiveSessions type SessionData = Cypress.Commands.Session.SessionData + /** - * rules for clearing session data: + * Session data should be cleared with spec browser launch. + * + * Rules for clearing session data: * - if page reloads due to top navigation OR user hard reload, session data should NOT be cleared * - if user relaunches the browser or launches a new spec, session data SHOULD be cleared * - session data SHOULD be cleared between specs in run mode - * - * therefore session data should be cleared with spec browser launch */ export default function (Commands, Cypress, cy) { - const { Promise } = Cypress - - const setActiveSession = (obj: ActiveSessions) => { - const currentSessions = cy.state('activeSessions') || {} - - const newSessions = { ...currentSessions, ...obj } - - cy.state('activeSessions', newSessions) - } - - const getActiveSession = (id: string): SessionData => { - const currentSessions = cy.state('activeSessions') || {} - - return currentSessions[id] - } - - const clearActiveSessions = () => { - const curSessions = cy.state('activeSessions') || {} - - cy.state('activeSessions', _.mapValues(curSessions, (v) => ({ ...v, hydrated: false }))) - } - - async function mapOrigins (origins) { - const currentOrigin = $Location.create(window.location.href).origin - - return _.uniq( - _.flatten(await Promise.map( - ([] as string[]).concat(origins), async (v) => { - if (v === '*') { - return _.keys(await Cypress.backend('get:rendered:html:origins')).concat([currentOrigin]) - } - - if (v === 'currentOrigin') return currentOrigin - - return $Location.create(v).origin - }, - )), - ) as string[] - } - - async function _setStorageOnOrigins (originOptions) { - const specWindow = cy.state('specWindow') - - const currentOrigin = $Location.create(window.location.href).origin - - const currentOriginIndex = _.findIndex(originOptions, { origin: currentOrigin }) - - if (currentOriginIndex !== -1) { - const opts = originOptions.splice(currentOriginIndex, 1)[0] - - if (!_.isEmpty(opts.localStorage)) { - if (opts.localStorage.clear) { - window.localStorage.clear() - } - - _.each(opts.localStorage.value, (val, key) => localStorage.setItem(key, val)) - } - - if (opts.sessionStorage) { - if (opts.sessionStorage.clear) { - window.sessionStorage.clear() - } - - _.each(opts.sessionStorage.value, (val, key) => sessionStorage.setItem(key, val)) - } - } - - if (_.isEmpty(originOptions)) { - return - } - - await setPostMessageLocalStorage(specWindow, originOptions) - } - - async function getAllHtmlOrigins () { - const currentOrigin = $Location.create(window.location.href).origin - - const origins = _.uniq([..._.keys(await Cypress.backend('get:rendered:html:origins')), currentOrigin]) as string[] - - return origins - } - function throwIfNoSessionSupport () { if (!Cypress.config('experimentalSessionAndOrigin')) { $errUtils.throwErrByPath('sessions.experimentNotEnabled', { args: { + // determine if using experimental session opt-in flag (removed in 9.6.0) to + // generate a coherent error message experimentalSessionSupport: Cypress.config('experimentalSessionSupport'), }, }) } } - const sessions = { - defineSession (options = {} as any): SessionData { - const sess_state: SessionData = { - id: options.id, - cookies: null, - localStorage: null, - setup: options.setup, - hydrated: false, - validate: options.validate, - } - - setActiveSession({ [sess_state.id]: sess_state }) - - return sess_state - }, - - async clearAllSavedSessions () { - clearActiveSessions() - - return Cypress.backend('clear:session', null) - }, - - async clearCurrentSessionData () { - window.localStorage.clear() - window.sessionStorage.clear() - - await Promise.all([ - sessions.clearStorage(), - sessions.clearCookies(), - ]) - }, - - async setSessionData (data) { - await sessions.clearCurrentSessionData() - const allHtmlOrigins = await getAllHtmlOrigins() - - let _localStorage = data.localStorage || [] - let _sessionStorage = data.sessionStorage || [] - - _.each(allHtmlOrigins, (v) => { - if (!_.find(_localStorage, v)) { - _localStorage = _localStorage.concat({ origin: v, clear: true }) - } - - if (!_.find(_sessionStorage, v)) { - _sessionStorage = _sessionStorage.concat({ origin: v, clear: true }) - } - }) - - await Promise.all([ - sessions.setStorage({ localStorage: _localStorage, sessionStorage: _sessionStorage }), - Cypress.automation('clear:cookies', null), - ]) - - await sessions.setCookies(data.cookies) - }, - - getCookies () { - return Cypress.automation('get:cookies', {}) - }, - - setCookies (data) { - return Cypress.automation('set:cookies', data) - }, - - async clearCookies () { - return Cypress.automation('clear:cookies', await sessions.getCookies()) - }, - - async getCurrentSessionData () { - const storage = await sessions.getStorage({ origin: '*' }) - - let cookies = [] as any[] - - cookies = await Cypress.automation('get:cookies', {}) - - const ses = { - ...storage, - cookies, - } - - return ses - }, - - getSession (id) { - return Cypress.backend('get:session', id) - }, - - /** - * 1) if we only need currentOrigin localStorage, access sync - * 2) if cross-origin http, we need to load in iframe from our proxy that will intercept all http reqs at /__cypress/automation/* - * and postMessage() the localStorage value to us - * 3) if cross-origin https, since we pass-thru https connections in the proxy, we need to - * send a message telling our proxy server to intercept the next req to the https domain, - * then follow 2) - */ - async getStorage (options = {}) { - const specWindow = cy.state('specWindow') - - if (!_.isObject(options)) { - throw new Error('getStorage() takes an object') - } - - const opts = _.defaults({}, options, { - origin: 'currentOrigin', - }) - - const currentOrigin = $Location.create(window.location.href).origin + const sessionsManager = new SessionsManager(Cypress, cy) + const sessions = sessionsManager.sessions - const origins = await mapOrigins(opts.origin) - - const getResults = () => { - return results - } - const results = { - localStorage: [] as any[], - sessionStorage: [] as any[], - } - - function pushValue (origin, value) { - if (!_.isEmpty(value.localStorage)) { - results.localStorage.push({ origin, value: value.localStorage }) - } - - if (!_.isEmpty(value.sessionStorage)) { - results.sessionStorage.push({ origin, value: value.sessionStorage }) - } - } - - const currentOriginIndex = origins.indexOf(currentOrigin) - - if (currentOriginIndex !== -1) { - origins.splice(currentOriginIndex, 1) - const currentOriginStorage = getCurrentOriginStorage() - - pushValue(currentOrigin, currentOriginStorage) - } - - if (_.isEmpty(origins)) { - return getResults() - } + Cypress.on('run:start', () => { + Cypress.on('test:before:run:async', () => { + if (Cypress.config('experimentalSessionAndOrigin')) { + sessionsManager.currentTestRegisteredSessions.clear() - if (currentOrigin.startsWith('https:')) { - _.remove(origins, (v) => v.startsWith('http:')) + return navigateAboutBlank(false) + .then(() => sessions.clearCurrentSessionData()) + .then(() => { + return Cypress.backend('reset:rendered:html:origins') + }) } - const postMessageResults = await getPostMessageLocalStorage(specWindow, origins) - - postMessageResults.forEach((val) => { - pushValue(val[0], val[1]) - }) - - return getResults() - }, - - async clearStorage () { - const origins = await getAllHtmlOrigins() - - const originOptions = origins.map((v) => ({ origin: v, clear: true })) - - await sessions.setStorage({ - localStorage: originOptions, - sessionStorage: originOptions, - }) - }, - - async setStorage (options: any, clearAll = false) { - const currentOrigin = $Location.create(window.location.href).origin as string - - const mapToCurrentOrigin = (v) => ({ ...v, origin: (v.origin && v.origin !== 'currentOrigin') ? $Location.create(v.origin).origin : currentOrigin }) - - const mappedLocalStorage = _.map(options.localStorage, (v) => { - const mapped = { origin: v.origin, localStorage: _.pick(v, 'value', 'clear') } - - if (clearAll) { - mapped.localStorage.clear = true - } - - return mapped - }).map(mapToCurrentOrigin) - - const mappedSessionStorage = _.map(options.sessionStorage, (v) => { - const mapped = { origin: v.origin, sessionStorage: _.pick(v, 'value', 'clear') } - - if (clearAll) { - mapped.sessionStorage.clear = true - } - - return mapped - }).map(mapToCurrentOrigin) - - const storageOptions = _.map(_.groupBy(mappedLocalStorage.concat(mappedSessionStorage), 'origin'), (v) => _.merge({}, ...v)) - - await _setStorageOnOrigins(storageOptions) - }, - - registerSessionHooks () { - Cypress.on('test:before:run:async', () => { - if (Cypress.config('experimentalSessionAndOrigin')) { - currentTestRegisteredSessions.clear() - - return navigateAboutBlank(false) - .then(() => sessions.clearCurrentSessionData()) - .then(() => { - return Cypress.backend('reset:rendered:html:origins') - }) - } - - return - }) - }, - } - - Cypress.on('run:start', () => { - sessions.registerSessionHooks() + return + }) }) Commands.addAll({ @@ -374,17 +92,18 @@ export default function (Commands, Cypress, cy) { }) } - let existingSession: SessionData = getActiveSession(id) + let existingSession: SessionData = sessionsManager.getActiveSession(id) + const isRegisteredSessionForTest = sessionsManager.currentTestRegisteredSessions.has(id) if (!setup) { - if (!existingSession || !currentTestRegisteredSessions.has(id)) { + if (!existingSession || !isRegisteredSessionForTest) { $errUtils.throwErrByPath('sessions.session.not_found', { args: { id } }) } } else { const isUniqSessionDefinition = !existingSession || existingSession.setup.toString().trim() !== setup.toString().trim() if (isUniqSessionDefinition) { - if (currentTestRegisteredSessions.has(id)) { + if (isRegisteredSessionForTest) { $errUtils.throwErrByPath('sessions.session.duplicateId', { args: { id: existingSession.id } }) } @@ -394,7 +113,7 @@ export default function (Commands, Cypress, cy) { validate: options.validate, }) - currentTestRegisteredSessions.set(id, true) + sessionsManager.currentTestRegisteredSessions.set(id, true) } } @@ -447,7 +166,7 @@ export default function (Commands, Cypress, cy) { _.extend(existingSession, data) existingSession.hydrated = true - setActiveSession({ [existingSession.id]: existingSession }) + sessionsManager.setActiveSession({ [existingSession.id]: existingSession }) dataLog.set({ consoleProps: () => getConsoleProps(existingSession), diff --git a/packages/driver/src/cy/commands/sessions/manager.ts b/packages/driver/src/cy/commands/sessions/manager.ts new file mode 100644 index 000000000000..3b63c3ad3a1c --- /dev/null +++ b/packages/driver/src/cy/commands/sessions/manager.ts @@ -0,0 +1,295 @@ +import _ from 'lodash' +import { $Location } from '../../../cypress/location' + +import { + getCurrentOriginStorage, + setPostMessageLocalStorage, + getPostMessageLocalStorage, +} from './utils' + +type ActiveSessions = Cypress.Commands.Session.ActiveSessions +type SessionData = Cypress.Commands.Session.SessionData + +export default class SessionsManager { + Cypress + cy + currentTestRegisteredSessions = new Map() + + constructor (Cypress, cy) { + this.Cypress = Cypress + this.cy = cy + } + + setActiveSession = (obj: ActiveSessions) => { + const currentSessions = this.cy.state('activeSessions') || {} + + const newSessions = { ...currentSessions, ...obj } + + this.cy.state('activeSessions', newSessions) + } + + getActiveSession = (id: string): SessionData => { + const currentSessions = this.cy.state('activeSessions') || {} + + return currentSessions[id] + } + + clearActiveSessions = () => { + const curSessions = this.cy.state('activeSessions') || {} + const clearedSessions: ActiveSessions = _.mapValues(curSessions, (v) => ({ ...v, hydrated: false })) + + this.cy.state('activeSessions', clearedSessions) + } + + mapOrigins = async (origins: string | Array): Promise> => { + const getOrigins = this.Cypress.Promise.map( + ([] as string[]).concat(origins), async (v) => { + if (v === '*') { + return await this.getAllHtmlOrigins() + } + + if (v === 'currentOrigin') { + return $Location.create(window.location.href).origin + } + + return $Location.create(v).origin + }, + ) + + return _.uniq(_.flatten(await getOrigins)) + } + + _setStorageOnOrigins = async (originOptions) => { + const specWindow = this.cy.state('specWindow') + + const currentOrigin = $Location.create(window.location.href).origin + + const currentOriginIndex = _.findIndex(originOptions, { origin: currentOrigin }) + + if (currentOriginIndex !== -1) { + const opts = originOptions.splice(currentOriginIndex, 1)[0] + + if (!_.isEmpty(opts.localStorage)) { + if (opts.localStorage.clear) { + window.localStorage.clear() + } + + _.each(opts.localStorage.value, (val, key) => localStorage.setItem(key, val)) + } + + if (opts.sessionStorage) { + if (opts.sessionStorage.clear) { + window.sessionStorage.clear() + } + + _.each(opts.sessionStorage.value, (val, key) => sessionStorage.setItem(key, val)) + } + } + + if (_.isEmpty(originOptions)) { + return + } + + await setPostMessageLocalStorage(specWindow, originOptions) + } + + getAllHtmlOrigins = async () => { + const currentOrigin = $Location.create(window.location.href).origin + const storedOrigins = await this.Cypress.backend('get:rendered:html:origins') + const origins = [..._.keys(storedOrigins), currentOrigin] + + return _.uniq(origins) + } + + // this the public api exposed to consumers as Cypress.session + sessions = { + defineSession: (options = {} as any): SessionData => { + const sess_state: SessionData = { + id: options.id, + cookies: null, + localStorage: null, + setup: options.setup, + hydrated: false, + validate: options.validate, + } + + this.setActiveSession({ [sess_state.id]: sess_state }) + + return sess_state + }, + + clearAllSavedSessions: async () => { + this.clearActiveSessions() + + return this.Cypress.backend('clear:session', null) + }, + + clearCurrentSessionData: async () => { + window.localStorage.clear() + window.sessionStorage.clear() + + await Promise.all([ + this.sessions.clearStorage(), + this.sessions.clearCookies(), + ]) + }, + + setSessionData: async (data) => { + await this.sessions.clearCurrentSessionData() + const allHtmlOrigins = await this.getAllHtmlOrigins() + + let _localStorage = data.localStorage || [] + let _sessionStorage = data.sessionStorage || [] + + _.each(allHtmlOrigins, (v) => { + if (!_.find(_localStorage, v)) { + _localStorage = _localStorage.concat({ origin: v, clear: true }) + } + + if (!_.find(_sessionStorage, v)) { + _sessionStorage = _sessionStorage.concat({ origin: v, clear: true }) + } + }) + + await Promise.all([ + this.sessions.setStorage({ localStorage: _localStorage, sessionStorage: _sessionStorage }), + this.Cypress.automation('clear:cookies', null), + ]) + + await this.sessions.setCookies(data.cookies) + }, + + getCookies: async () => { + return this.Cypress.automation('get:cookies', {}) + }, + + setCookies: async (cookies) => { + return this.Cypress.automation('set:cookies', cookies) + }, + + clearCookies: async () => { + return this.Cypress.automation('clear:cookies', await this.sessions.getCookies()) + }, + + getCurrentSessionData: async () => { + const [storage, cookies] = await Promise.all([ + this.sessions.getStorage({ origin: '*' }), + this.sessions.getCookies(), + ]) + + return { + ...storage, + cookies, + } + }, + + getSession: (id: string) => { + return this.Cypress.backend('get:session', id) + }, + + /** + * 1) if we only need currentOrigin localStorage, access sync + * 2) if cross-origin http, we need to load in iframe from our proxy that will intercept all http reqs at /__cypress/automation/* + * and postMessage() the localStorage value to us + * 3) if cross-origin https, since we pass-thru https connections in the proxy, we need to + * send a message telling our proxy server to intercept the next req to the https domain, + * then follow 2) + */ + getStorage: async (options = {}) => { + const specWindow = this.cy.state('specWindow') + + if (!_.isObject(options)) { + throw new Error('getStorage() takes an object') + } + + const opts = _.defaults({}, options, { + origin: 'currentOrigin', + }) + + const currentOrigin = $Location.create(window.location.href).origin + + const origins: Array = await this.mapOrigins(opts.origin) + + const results = { + localStorage: [] as any[], + sessionStorage: [] as any[], + } + + function pushValue (origin, value) { + if (!_.isEmpty(value.localStorage)) { + results.localStorage.push({ origin, value: value.localStorage }) + } + + if (!_.isEmpty(value.sessionStorage)) { + results.sessionStorage.push({ origin, value: value.sessionStorage }) + } + } + + const currentOriginIndex = origins.indexOf(currentOrigin) + + if (currentOriginIndex !== -1) { + origins.splice(currentOriginIndex, 1) + const currentOriginStorage = getCurrentOriginStorage() + + pushValue(currentOrigin, currentOriginStorage) + } + + if (_.isEmpty(origins)) { + return results + } + + if (currentOrigin.startsWith('https:')) { + _.remove(origins, (v) => v.startsWith('http:')) + } + + const postMessageResults = await getPostMessageLocalStorage(specWindow, origins) + + postMessageResults.forEach((val) => { + pushValue(val[0], val[1]) + }) + + return results + }, + + clearStorage: async () => { + const origins = await this.getAllHtmlOrigins() + + const originOptions = origins.map((v) => ({ origin: v, clear: true })) + + await this.sessions.setStorage({ + localStorage: originOptions, + sessionStorage: originOptions, + }) + }, + + setStorage: async (options: any, clearAll = false) => { + const currentOrigin = $Location.create(window.location.href).origin as string + + const mapToCurrentOrigin = (v) => ({ ...v, origin: (v.origin && v.origin !== 'currentOrigin') ? $Location.create(v.origin).origin : currentOrigin }) + + const mappedLocalStorage = _.map(options.localStorage, (v) => { + const mapped = { origin: v.origin, localStorage: _.pick(v, 'value', 'clear') } + + if (clearAll) { + mapped.localStorage.clear = true + } + + return mapped + }).map(mapToCurrentOrigin) + + const mappedSessionStorage = _.map(options.sessionStorage, (v) => { + const mapped = { origin: v.origin, sessionStorage: _.pick(v, 'value', 'clear') } + + if (clearAll) { + mapped.sessionStorage.clear = true + } + + return mapped + }).map(mapToCurrentOrigin) + + const storageOptions = _.map(_.groupBy(mappedLocalStorage.concat(mappedSessionStorage), 'origin'), (v) => _.merge({}, ...v)) + + await this._setStorageOnOrigins(storageOptions) + }, + } +}