Merge branch 'develop' into feature/simulated-top-cookie-handling

This commit is contained in:
Bill Glesias
2022-09-18 23:22:22 -04:00
committed by GitHub
111 changed files with 4001 additions and 611 deletions

View File

@@ -106,7 +106,7 @@ generates:
<<: *vueOperations
'./packages/frontend-shared/src/generated/graphql.ts':
documents: './packages/frontend-shared/src/{gql-components,graphql}/**/*.{vue,ts,tsx,js,jsx}'
documents: './packages/frontend-shared/src/{gql-components,graphql,composables}/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueOperations
###
# All GraphQL documents imported into the .spec.tsx files for component testing.

View File

@@ -94,6 +94,11 @@ export type MountResponse<T> = {
component: T
};
// 'zone.js/testing' is not properly aliasing `it.skip` but it does provide `xit`/`xspecify`
// Written up under https://github.com/angular/angular/issues/46297 but is not seeing movement
// so we'll patch here pending a fix in that library
globalThis.it.skip = globalThis.xit
/**
* Bootstraps the TestModuleMetaData passed to the TestBed
*

View File

@@ -12,6 +12,7 @@ exports['makeWebpackConfig ignores userland webpack `output.publicPath` and `dev
},
"mode": "development",
"optimization": {
"emitOnErrors": true,
"splitChunks": {
"chunks": "all"
}
@@ -33,6 +34,7 @@ exports['makeWebpackConfig ignores userland webpack `output.publicPath` and `dev
},
"mode": "development",
"optimization": {
"noEmitOnErrors": false,
"splitChunks": {
"chunks": "all"
}

View File

@@ -22,8 +22,8 @@ for (const project of WEBPACK_REACT) {
it('should mount a passing test', () => {
cy.visitApp()
cy.contains('app.component.cy.ts').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
cy.get('li.command').first().within(() => {
cy.get('.command-method').should('contain', 'mount')
cy.get('.command-message').should('contain', 'AppComponent')
@@ -33,8 +33,7 @@ for (const project of WEBPACK_REACT) {
it('should live-reload on src changes', () => {
cy.visitApp()
cy.contains('app.component.cy.ts').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject(
@@ -43,7 +42,7 @@ for (const project of WEBPACK_REACT) {
)
})
cy.get('.failed > .num').should('contain', 1)
cy.waitForSpecToFinish({ failCount: 1 })
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject(
@@ -52,7 +51,28 @@ for (const project of WEBPACK_REACT) {
)
})
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
it('should show compilation errors on src changes', () => {
cy.visitApp()
cy.contains('app.component.cy.ts').click()
cy.waitForSpecToFinish({ passCount: 1 })
// Create compilation error
cy.withCtx(async (ctx) => {
const componentFilePath = ctx.path.join('src', 'app', 'app.component.ts')
await ctx.actions.file.writeFileInProject(
componentFilePath,
(await ctx.file.readFileInProject(componentFilePath)).replace('class', 'classaaaaa'),
)
})
// The test should fail and the stack trace should appear in the command log
cy.waitForSpecToFinish({ failCount: 1 })
cy.contains('The following error originated from your test code, not from Cypress.').should('exist')
})
// TODO: fix flaky test https://github.com/cypress-io/cypress/issues/23455
@@ -67,8 +87,7 @@ for (const project of WEBPACK_REACT) {
})
cy.contains('new.component.cy.ts').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
})
}

View File

@@ -21,16 +21,14 @@ for (const project of WEBPACK_REACT) {
it('should mount a passing test', () => {
cy.visitApp()
cy.contains('App.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
it('should live-reload on src changes', () => {
cy.visitApp()
cy.contains('App.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject(
@@ -39,7 +37,7 @@ for (const project of WEBPACK_REACT) {
)
})
cy.get('.failed > .num').should('contain', 1)
cy.waitForSpecToFinish({ failCount: 1 })
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject(
@@ -48,7 +46,25 @@ for (const project of WEBPACK_REACT) {
)
})
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
it('should show compilation errors on src changes', () => {
cy.visitApp()
cy.contains('App.cy.js').click()
cy.waitForSpecToFinish({ passCount: 1 })
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject(
ctx.path.join('src', 'App.js'),
(await ctx.file.readFileInProject(ctx.path.join('src', 'App.js'))).replace('export', 'expart'),
)
})
// The test should fail and the stack trace should appear in the command log
cy.waitForSpecToFinish({ failCount: 1 })
cy.contains('The following error originated from your test code, not from Cypress.').should('exist')
})
it('should detect new spec', () => {
@@ -62,8 +78,7 @@ for (const project of WEBPACK_REACT) {
})
cy.contains('New.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
})
}

View File

@@ -21,16 +21,14 @@ for (const project of WEBPACK_REACT) {
it('should mount a passing test', () => {
cy.visitApp()
cy.contains('index.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
it('should live-reload on src changes', () => {
cy.visitApp()
cy.contains('index.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
cy.withCtx(async (ctx) => {
const indexPath = ctx.path.join('pages', 'index.js')
@@ -41,7 +39,7 @@ for (const project of WEBPACK_REACT) {
)
})
cy.get('.failed > .num', { timeout: 10000 }).should('contain', 1)
cy.waitForSpecToFinish({ failCount: 1 })
cy.withCtx(async (ctx) => {
const indexTestPath = ctx.path.join('pages', 'index.cy.js')
@@ -52,7 +50,28 @@ for (const project of WEBPACK_REACT) {
)
})
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
it('should show compilation errors on src changes', () => {
cy.visitApp()
cy.contains('index.cy.js').click()
cy.waitForSpecToFinish({ passCount: 1 })
// Create compilation error
cy.withCtx(async (ctx) => {
const indexPath = ctx.path.join('pages', 'index.js')
await ctx.actions.file.writeFileInProject(
indexPath,
(await ctx.file.readFileInProject(indexPath)).replace('export', 'expart'),
)
})
// The test should fail and the stack trace should appear in the command log
cy.waitForSpecToFinish({ failCount: 1 })
cy.contains('The following error originated from your test code, not from Cypress.').should('exist')
})
// TODO: fix flaky test https://github.com/cypress-io/cypress/issues/23417
@@ -70,16 +89,14 @@ for (const project of WEBPACK_REACT) {
})
cy.contains('New.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
// TODO: fix flaky test https://github.com/cypress-io/cypress/issues/23417
it.skip('should allow import of global styles in support file', () => {
cy.visitApp()
cy.contains('styles.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
})
}

View File

@@ -23,8 +23,7 @@ for (const project of PROJECTS) {
it('should mount a passing test and live-reload', () => {
cy.visitApp()
cy.contains('Tutorial.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
cy.withCtx(async (ctx) => {
const tutorialVuePath = ctx.path.join('components', 'Tutorial.vue')
@@ -35,7 +34,7 @@ for (const project of PROJECTS) {
)
})
cy.get('.failed > .num').should('contain', 1)
cy.waitForSpecToFinish({ failCount: 1 })
cy.withCtx(async (ctx) => {
const tutorialCyPath = ctx.path.join('components', 'Tutorial.cy.js')
@@ -46,7 +45,28 @@ for (const project of PROJECTS) {
)
})
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
it('should show compilation errors on src changes', () => {
cy.visitApp()
cy.contains('Tutorial.cy.js').click()
cy.waitForSpecToFinish({ passCount: 1 })
// Create compilation error
cy.withCtx(async (ctx) => {
const tutorialVuePath = ctx.path.join('components', 'Tutorial.vue')
await ctx.actions.file.writeFileInProject(
tutorialVuePath,
(await ctx.file.readFileInProject(tutorialVuePath)).replace('export', 'expart'),
)
})
// The test should fail and the stack trace should appear in the command log
cy.waitForSpecToFinish({ failCount: 1 })
cy.contains('The following error originated from your test code, not from Cypress.').should('exist')
})
// TODO: fix flaky test https://github.com/cypress-io/cypress/issues/23455
@@ -64,8 +84,7 @@ for (const project of PROJECTS) {
})
cy.contains('New.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
})
})
}

View File

@@ -21,12 +21,32 @@ for (const project of PROJECTS) {
it('should mount a passing test', () => {
cy.visitApp()
cy.contains('HelloWorld.cy.js').click()
cy.waitForSpecToFinish()
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
cy.get('.commands-container').within(() => {
cy.contains('mount')
cy.contains('<HelloWorld ... />')
})
})
it('should show compilation errors on src changes', () => {
cy.visitApp()
cy.contains('HelloWorld.cy.js').click()
cy.waitForSpecToFinish({ passCount: 1 })
// Create compilation error
cy.withCtx(async (ctx) => {
const helloWorldVuePath = ctx.path.join('src', 'components', 'HelloWorld.vue')
await ctx.actions.file.writeFileInProject(
helloWorldVuePath,
(await ctx.file.readFileInProject(helloWorldVuePath)).replace('export', 'expart'),
)
})
// The test should fail and the stack trace should appear in the command log
cy.waitForSpecToFinish({ failCount: 1 })
cy.contains('The following error originated from your test code, not from Cypress.').should('exist')
})
})
}

View File

@@ -1,4 +1,6 @@
/// <reference types="cypress" />
import type { ExpectedResults } from '@packages/app/cypress/e2e/support/execute-spec'
import { waitForSpecToFinish } from '@packages/app/cypress/e2e/support/execute-spec'
declare global {
namespace Cypress {
@@ -11,24 +13,9 @@ declare global {
* 3. Waits (with a timeout of 30s) for the Rerun all tests button to be present. This ensures all tests have completed
*
*/
waitForSpecToFinish()
waitForSpecToFinish(expectedResults?: ExpectedResults): void
}
}
}
// Here we export the function with no intention to import it
// This only tells the typescript type checker that this definitely is a module
// This way, we are allowed to use the global namespace declaration
export const waitForSpecToFinish = () => {
// First ensure the test is loaded
cy.get('.passed > .num').should('contain', '--')
cy.get('.failed > .num').should('contain', '--')
// Then ensure the tests are running
cy.contains('Your tests are loading...').should('not.exist')
// Then ensure the tests have finished
cy.get('[aria-label="Rerun all tests"]', { timeout: 30000 })
}
Cypress.Commands.add('waitForSpecToFinish', waitForSpecToFinish)

View File

@@ -24,9 +24,18 @@ export function makeDefaultWebpackConfig (
debug(`Using HtmlWebpackPlugin version ${version} from ${importPath}`)
const optimization = <Record<string, any>>{}
if (config.sourceWebpackModulesResult.webpack.majorVersion === 5) {
optimization.emitOnErrors = true
} else {
optimization.noEmitOnErrors = false
}
const finalConfig = {
mode: 'development',
optimization: {
...optimization,
splitChunks: {
chunks: 'all',
},

View File

@@ -2,7 +2,6 @@ import { expect } from 'chai'
import EventEmitter from 'events'
import snapshot from 'snap-shot-it'
import { WebpackDevServerConfig } from '../src/devServer'
import { sourceDefaultWebpackDependencies } from '../src/helpers/sourceRelativeWebpackModules'
import { CYPRESS_WEBPACK_ENTRYPOINT, makeWebpackConfig } from '../src/makeWebpackConfig'
import { createModuleMatrixResult } from './test-helpers/createModuleMatrixResult'
@@ -24,12 +23,18 @@ describe('makeWebpackConfig', () => {
progress: true,
overlay: true, // This will be overridden by makeWebpackConfig.ts
},
optimization: {
noEmitOnErrors: true, // This will be overridden by makeWebpackConfig.ts
},
},
devServerEvents: new EventEmitter(),
}
const actual = await makeWebpackConfig({
devServerConfig,
sourceWebpackModulesResult: sourceDefaultWebpackDependencies(devServerConfig),
sourceWebpackModulesResult: createModuleMatrixResult({
webpack: 4,
webpackDevServer: 3,
}),
})
// plugins contain circular deps which cannot be serialized in a snapshot.
@@ -64,13 +69,16 @@ describe('makeWebpackConfig', () => {
overlay: true, // This will be overridden by makeWebpackConfig.ts
},
},
optimization: {
emitOnErrors: false, // This will be overridden by makeWebpackConfig.ts
},
},
devServerEvents: new EventEmitter(),
}
const actual = await makeWebpackConfig({
devServerConfig,
sourceWebpackModulesResult: createModuleMatrixResult({
webpack: 4,
webpack: 5,
webpackDevServer: 4,
}),
})

View File

@@ -202,6 +202,8 @@ const preprocessor: WebpackPreprocessor = (options: PreprocessorOptions = {}): F
// we need to set entry and output
entry,
output: {
// disable automatic publicPath
publicPath: '',
path: path.dirname(outputPath),
filename: path.basename(outputPath),
},
@@ -220,6 +222,10 @@ const preprocessor: WebpackPreprocessor = (options: PreprocessorOptions = {}): F
// override typescript to always generate proper source maps
overrideSourceMaps(true, options.typescript)
// To support dynamic imports, we have to disable any code splitting.
debug('Limiting number of chunks to 1')
opts.plugins = (opts.plugins || []).concat(new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }))
})
.value() as any

View File

@@ -10,6 +10,11 @@ const expect = chai.expect
chai.use(require('sinon-chai'))
const webpack = sinon.stub()
const LimitChunkCountPluginStub = sinon.stub()
webpack.optimize = {
LimitChunkCountPlugin: LimitChunkCountPluginStub,
}
mockery.enable({
warnOnUnregistered: false,
@@ -149,6 +154,7 @@ describe('webpack preprocessor', function () {
return this.run().then(() => {
expect(webpack.lastCall.args[0].output).to.eql({
publicPath: '',
path: 'output',
filename: 'output.ts.js',
})

View File

@@ -611,7 +611,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.get('[data-cy="copy-button"]').click()
cy.contains('Copied!')
cy.withRetryableCtx((ctx) => {
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('cypress run --component --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --component --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
})
})
@@ -624,7 +624,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.get('[data-cy="copy-button"]').click()
cy.contains('Copied!')
cy.withRetryableCtx((ctx) => {
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('cypress run --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
})
})
})

View File

@@ -1,5 +1,12 @@
import { shouldHaveTestResults } from '../runner/support/spec-loader'
export interface ExpectedResults
{
passCount?: number
failCount?: number
pendingCount?: number
}
declare global {
namespace Cypress {
interface Chainable {
@@ -11,11 +18,7 @@ declare global {
* 3. Waits (with a timeout of 30s) for the Rerun all tests button to be present. This ensures all tests have completed
*
*/
waitForSpecToFinish(expectedResults?: {
passCount?: number
failCount?: number
pendingCount?: number
}): void
waitForSpecToFinish(expectedResults?: ExpectedResults): void
}
}
}
@@ -29,7 +32,7 @@ export const waitForSpecToFinish = (expectedResults) => {
cy.get('.failed > .num').should('contain', '--')
// Then ensure the tests are running
cy.contains('Your tests are loading...', { timeout: 10000 }).should('not.exist')
cy.contains('Your tests are loading...', { timeout: 20000 }).should('not.exist')
// Then ensure the tests have finished
cy.get('[aria-label="Rerun all tests"]', { timeout: 30000 })

View File

@@ -1,10 +1,10 @@
import Bluebird from 'bluebird'
import { EventEmitter } from 'events'
import type { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store'
import type { RunState } from '@packages/types/src/driver'
import type MobX from 'mobx'
import type { LocalBusEmitsMap, LocalBusEventMap, DriverToLocalBus, SocketToDriverMap } from './event-manager-types'
import type { AutomationElementId, FileDetails } from '@packages/types'
import type { RunState, CachedTestState, AutomationElementId, FileDetails, ReporterStartInfo, ReporterRunState } from '@packages/types'
import { logger } from './logger'
import type { Socket } from '@packages/socket/lib/browser'
@@ -34,7 +34,7 @@ interface AddGlobalListenerOptions {
randomString: string
}
const driverToReporterEvents = 'paused session:add'.split(' ')
const driverToReporterEvents = 'paused'.split(' ')
const driverToLocalAndReporterEvents = 'run:start run:end'.split(' ')
const driverToSocketEvents = 'backend:request automation:request mocha recorder:frame'.split(' ')
const driverTestEvents = 'test:before:run:async test:after:run'.split(' ')
@@ -87,8 +87,7 @@ export class EventManager {
const rerun = () => {
if (!this) {
// if the tests have been reloaded
// then there is nothing to rerun
// if the tests have been reloaded then there is nothing to rerun
return
}
@@ -249,10 +248,10 @@ export class EventManager {
this.saveState(state)
})
this.reporterBus.on('clear:session', () => {
this.reporterBus.on('clear:all:sessions', () => {
if (!Cypress) return
Cypress.backend('clear:session')
Cypress.backend('clear:sessions', true)
.then(rerun)
})
@@ -336,8 +335,8 @@ export class EventManager {
// @ts-ignore
const $window = this.$CypressDriver.$(window)
// This is a test-only even. It's used to
// trigger a re-reun for the drive rerun.cy.js spec.
// This is a test-only event. It's used to
// trigger a rerun for the driver rerun.cy.js spec.
$window.on('test:trigger:rerun', rerun)
// when we actually unload then
@@ -395,9 +394,9 @@ export class EventManager {
return Cypress.initialize({
$autIframe,
onSpecReady: () => {
// get the current runnable in case we reran mid-test due to a visit
// to a new domain
this.ws.emit('get:existing:run:state', (state: RunState = {}) => {
// get the current runnable states and cached test state
// in case we reran mid-test due to a visit to a new domain
this.ws.emit('get:cached:test:state', (runState: RunState = {}, testState: CachedTestState) => {
if (!Cypress.runner) {
// the tests have been reloaded
return
@@ -405,40 +404,40 @@ export class EventManager {
const hideCommandLog = window.__CYPRESS_CONFIG__.hideCommandLog
this.studioStore.initialize(config, state)
this.studioStore.initialize(config, runState)
const runnables = Cypress.runner.normalizeAll(state.tests, hideCommandLog)
const runnables = Cypress.runner.normalizeAll(runState.tests, hideCommandLog)
const run = () => {
performance.mark('initialize-end')
performance.measure('initialize', 'initialize-start', 'initialize-end')
this._runDriver(state)
this._runDriver(runState, testState)
}
if (!hideCommandLog) {
this.reporterBus.emit('runnables:ready', runnables)
}
if (state?.numLogs) {
Cypress.runner.setNumLogs(state.numLogs)
if (runState?.numLogs) {
Cypress.runner.setNumLogs(runState.numLogs)
}
if (state.startTime) {
Cypress.runner.setStartTime(state.startTime)
if (runState.startTime) {
Cypress.runner.setStartTime(runState.startTime)
}
if (config.isTextTerminal && !state.currentId) {
if (config.isTextTerminal && !runState.currentId) {
// we are in run mode and it's the first load
// store runnables in backend and maybe send to dashboard
return this.ws.emit('set:runnables:and:maybe:record:tests', runnables, run)
}
if (state.currentId) {
if (runState.currentId) {
// if we have a currentId it means
// we need to tell the Cypress to skip
// ahead to that test
Cypress.runner.resumeAtTest(state.currentId, state.emissions)
Cypress.runner.resumeAtTest(runState.currentId, runState.emissions)
}
return run()
@@ -464,7 +463,7 @@ export class EventManager {
}
return new Bluebird((resolve) => {
this.reporterBus.emit('reporter:collect:run:state', (reporterState) => {
this.reporterBus.emit('reporter:collect:run:state', (reporterState: ReporterRunState) => {
resolve({
...reporterState,
studio: {
@@ -749,9 +748,9 @@ export class EventManager {
window.top.addEventListener('message', crossOriginOnMessageRef, false)
}
_runDriver (state) {
_runDriver (runState: RunState, testState: CachedTestState) {
performance.mark('run-s')
Cypress.run(() => {
Cypress.run(testState, () => {
performance.mark('run-e')
performance.measure('run', 'run-s', 'run-e')
})
@@ -760,14 +759,14 @@ export class EventManager {
this.reporterBus.emit('reporter:start', {
startTime: Cypress.runner.getStartTime(),
numPassed: state.passed,
numFailed: state.failed,
numPending: state.pending,
autoScrollingEnabled: state.autoScrollingEnabled,
isSpecsListOpen: state.isSpecsListOpen,
scrollTop: state.scrollTop,
numPassed: runState.passed,
numFailed: runState.failed,
numPending: runState.pending,
autoScrollingEnabled: runState.autoScrollingEnabled,
isSpecsListOpen: runState.isSpecsListOpen,
scrollTop: runState.scrollTop,
studioActive: hasRunnableId,
})
} as ReporterStartInfo)
}
stop () {
@@ -783,8 +782,8 @@ export class EventManager {
state.setIsLoading(true)
if (!isRerun) {
// only clear session state when a new spec is selected
Cypress.backend('reset:session:state')
// only clear test state when a new spec is selected
Cypress.backend('reset:cached:test:state')
}
// when we are re-running we first need to stop cypress always

View File

@@ -61,7 +61,7 @@ const firstRecordKey = computed(() => {
const recordCommand = computed(() => {
const componentFlagOrSpace = props.gql.currentTestingType === 'component' ? ' --component ' : ' '
return `cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
return `npx cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
})
</script>

View File

@@ -1,7 +1,7 @@
import SpecsListBanners from './SpecsListBanners.vue'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { SpecsListBannersFragment, SpecsListBannersFragmentDoc } from '../generated/graphql-test'
import { SpecsListBannersFragment, SpecsListBannersFragmentDoc, UseCohorts_DetermineCohortDocument } from '../generated/graphql-test'
import interval from 'human-interval'
import { CloudUserStubs, CloudProjectStubs } from '@packages/graphql/test/stubCloudTypes'
import { AllowedState, BannerIds } from '@packages/types'
@@ -92,6 +92,12 @@ describe('<SpecsListBanners />', () => {
})
context('banner conditions are met and when cypress use >= 4 days', () => {
beforeEach(() => {
cy.stubMutationResolver(UseCohorts_DetermineCohortDocument, (defineResult) => {
return defineResult({ determineCohort: { __typename: 'Cohort', name: 'foo', cohort: 'A' } })
})
})
it('should render when not previously-dismissed', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4))
cy.get(`[data-cy="${bannerTestId}"]`).should('be.visible')

View File

@@ -116,19 +116,22 @@
:has-banner-been-shown="hasRecordBannerBeenShown"
/>
<ConnectProjectBanner
v-else-if="showConnectBanner"
v-else-if="showConnectBanner && cohorts.connectProject?.value"
v-model="showConnectBanner"
:has-banner-been-shown="hasConnectBannerBeenShown"
:cohort-option="cohorts.connectProject.value"
/>
<CreateOrganizationBanner
v-else-if="showCreateOrganizationBanner"
v-else-if="showCreateOrganizationBanner && cohorts.organization?.value"
v-model="showCreateOrganizationBanner"
:has-banner-been-shown="hasCreateOrganizationBannerBeenShown"
:cohort-option="cohorts.organization.value"
/>
<LoginBanner
v-else-if="showLoginBanner"
v-else-if="showLoginBanner && cohorts.login?.value"
v-model="showLoginBanner"
:has-banner-been-shown="hasLoginBannerBeenShown"
:cohort-option="cohorts.login.value"
/>
</template>
@@ -143,13 +146,14 @@ import ConnectIcon from '~icons/cy/chain-link_x16.svg'
import WarningIcon from '~icons/cy/warning_x16.svg'
import RefreshIcon from '~icons/cy/action-restart_x16'
import { useRoute } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
import RequestAccessButton from './RequestAccessButton.vue'
import { gql, useSubscription } from '@urql/vue'
import { SpecsListBannersFragment, SpecsListBanners_CheckCloudOrgMembershipDocument } from '../generated/graphql'
import interval from 'human-interval'
import { AllowedState, BannerIds } from '@packages/types'
import { LoginBanner, CreateOrganizationBanner, ConnectProjectBanner, RecordBanner } from './banners'
import { CohortConfig, useCohorts } from '@packages/frontend-shared/src/composables/useCohorts'
const route = useRoute()
const { t } = useI18n()
@@ -228,10 +232,10 @@ const showConnectBanner = ref(false)
const showCreateOrganizationBanner = ref(false)
const showLoginBanner = ref(false)
const hasRecordBannerBeenShown = ref(true)
const hasConnectBannerBeenShown = ref(true)
const hasCreateOrganizationBannerBeenShown = ref(true)
const hasLoginBannerBeenShown = ref(true)
const hasRecordBannerBeenShown = ref(false)
const hasConnectBannerBeenShown = ref(false)
const hasCreateOrganizationBannerBeenShown = ref(false)
const hasLoginBannerBeenShown = ref(false)
watch(
() => ([props.isSpecNotFound, props.isOffline, props.isFetchError, props.isProjectNotFound, props.isProjectUnauthorized]),
@@ -272,6 +276,50 @@ watch(
{ immediate: true },
)
const bannerCohortOptions = {
[BannerIds.ACI_082022_LOGIN]: [
{ cohort: 'A', value: t('specPage.banners.login.contentA') },
{ cohort: 'B', value: t('specPage.banners.login.contentB') },
],
[BannerIds.ACI_082022_CREATE_ORG]: [
{ cohort: 'A', value: t('specPage.banners.createOrganization.titleA') },
{ cohort: 'B', value: t('specPage.banners.createOrganization.titleB') },
],
[BannerIds.ACI_082022_CONNECT_PROJECT]: [
{ cohort: 'A', value: t('specPage.banners.connectProject.contentA') },
{ cohort: 'B', value: t('specPage.banners.connectProject.contentB') },
],
}
const cohortBuilder = useCohorts()
const getCohortForBanner = (bannerId: string) => {
const cohortConfig: CohortConfig = {
name: bannerId,
options: bannerCohortOptions[bannerId],
}
return cohortBuilder.getCohort(cohortConfig)
}
type BannerType = 'login' | 'connectProject' | 'organization'
const cohorts: Partial<Record<BannerType, ReturnType<typeof getCohortForBanner>>> = {}
watchEffect(() => {
if (!cohorts.login && showLoginBanner.value) {
cohorts.login = getCohortForBanner(BannerIds.ACI_082022_LOGIN)
}
if (!cohorts.organization && showCreateOrganizationBanner.value) {
cohorts.organization = getCohortForBanner(BannerIds.ACI_082022_CREATE_ORG)
}
if (!cohorts.connectProject && showConnectBanner.value) {
cohorts.connectProject = getCohortForBanner(BannerIds.ACI_082022_CONNECT_PROJECT)
}
})
function hasBannerBeenDismissed (bannerId: string) {
const bannersState = (props.gql.currentProject?.savedState as AllowedState)?.banners

View File

@@ -3,32 +3,44 @@ import ConnectProjectBanner from './ConnectProjectBanner.vue'
import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<ConnectProjectBanner />', () => {
const cohortOption = { cohort: 'A', value: defaultMessages.specPage.banners.connectProject.contentA }
it('should render expected content', () => {
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} /> })
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.contains(defaultMessages.specPage.banners.connectProject.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.connectProject.content).should('be.visible')
cy.contains(defaultMessages.specPage.banners.connectProject.contentA).should('be.visible')
cy.contains(defaultMessages.specPage.banners.connectProject.buttonLabel).should('be.visible')
cy.percySnapshot()
})
it('should record expected event on mount', () => {
const recordEvent = cy.stub().as('recordEvent')
context('events', () => {
beforeEach(() => {
const recordEvent = cy.stub().as('recordEvent')
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
return defineResult({ recordEvent: true })
return defineResult({ recordEvent: true })
})
})
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={false} /> })
it('should record expected event on mount', () => {
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={false} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Create project',
medium: 'Specs Create Project Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Create project',
medium: 'Specs Create Project Banner',
messageId: Cypress.sinon.match.string,
cohort: 'A',
})
})
it('should not record event on mount if already shown', () => {
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('not.have.been.called')
})
})
})

View File

@@ -1,6 +1,6 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_CONNECT_PROJECT"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="connect-project-banner"
status="info"
@@ -12,12 +12,12 @@
:event-data="{
campaign: 'Create project',
medium: 'Specs Create Project Banner',
cohort: '' // TODO Connect cohort
cohort: cohortOption.cohort
}"
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.connectProject.content') }}
{{ cohortOption.value }}
</p>
<Button
@@ -45,6 +45,7 @@ import ConnectIcon from '~icons/cy/chain-link_x16.svg'
import { useI18n } from '@cy/i18n'
import Button from '@cy/components/Button.vue'
import TrackedBanner from './TrackedBanner.vue'
import type { CohortOption } from '@packages/frontend-shared/src/composables/useCohorts'
import { BannerIds } from '@packages/types'
import { ref } from 'vue'
import { ConnectProjectBannerDocument } from '../../generated/graphql'
@@ -56,19 +57,18 @@ query ConnectProjectBanner {
}
`
withDefaults(defineProps<{
defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
cohortOption: CohortOption
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_CONNECT_PROJECT
const isProjectConnectOpen = ref(false)
const cloudModalsQuery = useQuery({ query: ConnectProjectBannerDocument, pause: true })

View File

@@ -3,6 +3,8 @@ import CreateOrganizationBanner from './CreateOrganizationBanner.vue'
import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<CreateOrganizationBanner />', () => {
const cohortOption = { cohort: 'A', value: defaultMessages.specPage.banners.createOrganization.titleA }
it('should render expected content', () => {
const linkHref = 'http://dummy.cypress.io/organizations/create'
@@ -12,9 +14,9 @@ describe('<CreateOrganizationBanner />', () => {
cloudOrganizationsUrl: linkHref,
} as any
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} /> })
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.contains(defaultMessages.specPage.banners.createOrganization.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.titleA).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.content).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.buttonLabel).should('be.visible')
@@ -25,22 +27,32 @@ describe('<CreateOrganizationBanner />', () => {
cy.percySnapshot()
})
it('should record expected event on mount', () => {
const recordEvent = cy.stub().as('recordEvent')
context('events', () => {
beforeEach(() => {
const recordEvent = cy.stub().as('recordEvent')
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
return defineResult({ recordEvent: true })
return defineResult({ recordEvent: true })
})
})
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={false} /> })
it('should record expected event on mount', () => {
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={false} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Set up your organization',
medium: 'Specs Create Organization Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Set up your organization',
medium: 'Specs Create Organization Banner',
messageId: Cypress.sinon.match.string,
cohort: 'A',
})
})
it('should not record event on mount if already shown', () => {
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('not.have.been.called')
})
})
})

View File

@@ -1,10 +1,10 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_CREATE_ORG"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="create-organization-banner"
status="info"
:title="t('specPage.banners.createOrganization.title')"
:title="cohortOption.value"
class="mb-16px"
:icon="OrganizationIcon"
dismissible
@@ -12,7 +12,7 @@
:event-data="{
campaign: 'Set up your organization',
medium: 'Specs Create Organization Banner',
cohort: '' // TODO Connect cohort
cohort: cohortOption.cohort
}"
@update:model-value="value => emit('update:modelValue', value)"
>
@@ -36,6 +36,7 @@
import OrganizationIcon from '~icons/cy/office-building_x16.svg'
import { useI18n } from '@cy/i18n'
import TrackedBanner from './TrackedBanner.vue'
import type { CohortOption } from '@packages/frontend-shared/src/composables/useCohorts'
import { BannerIds } from '@packages/types'
import { CreateOrganizationBannerDocument } from '../../generated/graphql'
import { gql, useQuery } from '@urql/vue'
@@ -52,19 +53,18 @@ query CreateOrganizationBanner {
}
`
withDefaults(defineProps<{
const props = defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
cohortOption: CohortOption
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_CREATE_ORG
const query = useQuery({ query: CreateOrganizationBannerDocument })
@@ -80,6 +80,7 @@ const createOrganizationUrl = computed(() => {
params: {
utm_medium: 'Specs Create Organization Banner',
utm_campaign: 'Set up your organization',
utm_content: props.cohortOption.cohort,
},
})
})

View File

@@ -3,32 +3,44 @@ import LoginBanner from './LoginBanner.vue'
import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<LoginBanner />', () => {
const cohortOption = { cohort: 'A', value: defaultMessages.specPage.banners.login.contentA }
it('should render expected content', () => {
cy.mount({ render: () => <LoginBanner modelValue={true} /> })
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.contains(defaultMessages.specPage.banners.login.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.login.content).should('be.visible')
cy.contains(defaultMessages.specPage.banners.login.contentA).should('be.visible')
cy.contains(defaultMessages.specPage.banners.login.buttonLabel).should('be.visible')
cy.percySnapshot()
})
it('should record expected event on mount', () => {
const recordEvent = cy.stub().as('recordEvent')
context('events', () => {
beforeEach(() => {
const recordEvent = cy.stub().as('recordEvent')
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
return defineResult({ recordEvent: true })
return defineResult({ recordEvent: true })
})
})
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={false} /> })
it('should record expected event on mount', () => {
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={false} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Log In',
medium: 'Specs Login Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Log In',
medium: 'Specs Login Banner',
messageId: Cypress.sinon.match.string,
cohort: cohortOption.cohort,
})
})
it('should not record event on mount if already shown', () => {
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('not.have.been.called')
})
})
})

View File

@@ -1,6 +1,6 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_LOGIN"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="login-banner"
status="info"
@@ -12,12 +12,12 @@
:event-data="{
campaign: 'Log In',
medium: 'Specs Login Banner',
cohort: '' // TODO Connect cohort
cohort: cohortOption.cohort
}"
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.login.content') }}
{{ cohortOption.value }}
</p>
<Button
@@ -33,6 +33,7 @@
v-model="isLoginOpen"
:gql="loginModalQuery.data.value"
utm-medium="Specs Login Banner"
:utm-content="cohortOption.cohort"
/>
</TrackedBanner>
</template>
@@ -45,6 +46,7 @@ import { useI18n } from '@cy/i18n'
import Button from '@cy/components/Button.vue'
import { LoginBannerDocument } from '../../generated/graphql'
import TrackedBanner from './TrackedBanner.vue'
import type { CohortOption } from '@packages/frontend-shared/src/composables/useCohorts'
import { BannerIds } from '@packages/types'
import LoginModal from '@cy/gql-components/topnav/LoginModal.vue'
@@ -54,19 +56,18 @@ query LoginBanner {
}
`
withDefaults(defineProps<{
defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
cohortOption: CohortOption
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_LOGIN
const isLoginOpen = ref(false)
const loginModalQuery = useQuery({ query: LoginBannerDocument, pause: true })

View File

@@ -4,7 +4,7 @@ import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<RecordBanner />', () => {
it('should render expected content', () => {
cy.mount({ render: () => <RecordBanner modelValue={true} /> })
cy.mount({ render: () => <RecordBanner modelValue={true} hasBannerBeenShown={false} /> })
cy.gqlStub.Query.currentProject = {
id: 'test_id',
@@ -24,7 +24,7 @@ describe('<RecordBanner />', () => {
cy.contains(defaultMessages.specPage.banners.record.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.record.content).should('be.visible')
cy.findByText('cypress run --component --record --key abcd-efg-1234')
cy.findByText('npx cypress run --component --record --key abcd-efg-1234')
cy.percySnapshot()
})
@@ -44,7 +44,7 @@ describe('<RecordBanner />', () => {
campaign: 'Record Runs',
medium: 'Specs Record Runs Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cohort: 'n/a',
})
})
})

View File

@@ -1,6 +1,6 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_RECORD"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="record-banner"
status="info"
@@ -12,7 +12,7 @@
:event-data="{
campaign: 'Record Runs',
medium: 'Specs Record Runs Banner',
cohort: '' // TODO Connect cohort
cohort: 'n/a'
}"
@update:model-value="value => emit('update:modelValue', value)"
>
@@ -58,29 +58,28 @@ query RecordBanner {
}
`
withDefaults(defineProps<{
defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_RECORD
const query = useQuery({ query: RecordBannerDocument })
const firstRecordKey = computed(() => {
return (query.data?.value?.currentProject?.cloudProject?.__typename === 'CloudProject' && query.data.value.currentProject.cloudProject.recordKeys?.[0]?.key) ?? '<record-key>'
})
const recordCommand = computed(() => {
const componentFlagOrSpace = query.data?.value?.currentProject?.currentTestingType === 'component' ? ' --component ' : ' '
return `cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
return `npx cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
})
</script>

View File

@@ -1,5 +1,5 @@
import TrackedBanner from './TrackedBanner.vue'
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { TrackedBanner_RecordBannerSeenDocument, TrackedBanner_SetProjectStateDocument } from '../../generated/graphql'
describe('<TrackedBanner />', () => {
@@ -25,7 +25,7 @@ describe('<TrackedBanner />', () => {
// Initially mount as visible
// @ts-ignore
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} hasBannerBeenShown={false} /> })
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} hasBannerBeenShown={false} eventData={{} as any}/> })
cy.get('[data-cy="banner"]').as('banner')
@@ -48,7 +48,7 @@ describe('<TrackedBanner />', () => {
// Initially mount as visible
// @ts-ignore
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} dismissible hasBannerBeenShown={false} /> })
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} dismissible hasBannerBeenShown={false} eventData={{} as any} /> })
cy.get('[data-cy="banner"]').as('banner')
@@ -74,32 +74,55 @@ describe('<TrackedBanner />', () => {
})
context('when banner not previously shown', () => {
let eventData
beforeEach(() => {
const setProjectStateStub = cy.stub().as('setProjectState')
const hasBannerBeenShown = ref(false)
// mock setting the project state which would reactively set the hasBannerBeenShown ref
cy.stubMutationResolver(TrackedBanner_SetProjectStateDocument, (defineResult, event) => {
setProjectStateStub(event)
const preference = JSON.parse(event.value)
expect(preference).to.have.nested.property('banners.test-banner.lastShown')
hasBannerBeenShown.value = true
return defineResult({ setPreferences: null }) // do not care about return value here
})
eventData = reactive({ campaign: 'CAM', medium: 'MED', cohort: 'COH' })
cy.mount({
render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={false} eventData={{ campaign: 'CAM', medium: 'MED', cohort: 'COH' }} />,
render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={hasBannerBeenShown.value} eventData={eventData} />,
})
})
it('should record event', () => {
cy.get('@recordEvent').should('have.been.calledOnce')
eventData.cohort = 'COH2' //Change reactive variable to confirm the record event is not recorded a second time
cy.get('@recordEvent').should(
'have.been.calledWith',
'have.been.calledOnceWith',
Cypress.sinon.match({ campaign: 'CAM', messageId: Cypress.sinon.match.string, medium: 'MED', cohort: 'COH' }),
)
})
it('should debounce event recording', () => {
eventData.cohort = 'COH'
cy.wait(250)
cy.get('@recordEvent').should('have.been.calledOnce')
})
})
context('when banner has been previously shown', () => {
let eventData
beforeEach(() => {
cy.mount({ render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={true} eventData={{} as any} /> })
eventData = reactive({ campaign: 'CAM', medium: 'MED', cohort: undefined })
cy.mount({ render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={true} eventData={eventData} /> })
})
it('should not record event', () => {
eventData.cohort = 'COH'
cy.get('@recordEvent').should('not.have.been.called')
})
})

View File

@@ -10,6 +10,7 @@ import {
BrowserActions,
DevActions,
AuthActions,
CohortsActions,
} from './actions'
import { ErrorActions } from './actions/ErrorActions'
import { EventCollectorActions } from './actions/EventCollectorActions'
@@ -83,4 +84,9 @@ export class DataActions {
get eventCollector () {
return new EventCollectorActions(this.ctx)
}
@cached
get cohorts () {
return new CohortsActions(this.ctx)
}
}

View File

@@ -9,7 +9,7 @@ import _ from 'lodash'
import 'server-destroy'
import { AppApiShape, DataEmitterActions, LocalSettingsApiShape, ProjectApiShape } from './actions'
import { AppApiShape, CohortsApiShape, DataEmitterActions, LocalSettingsApiShape, ProjectApiShape } from './actions'
import type { NexusGenAbstractTypeMembers } from '@packages/graphql/src/gen/nxs.gen'
import type { AuthApiShape } from './actions/AuthActions'
import type { ElectronApiShape } from './actions/ElectronActions'
@@ -70,6 +70,7 @@ export interface DataContextConfig {
projectApi: ProjectApiShape
electronApi: ElectronApiShape
browserApi: BrowserApiShape
cohortsApi: CohortsApiShape
}
export interface GraphQLRequestInfo {
@@ -131,6 +132,10 @@ export class DataContext {
return this._config.localSettingsApi
}
get cohortsApi () {
return this._config.cohortsApi
}
get isGlobalMode () {
return this.appData.isGlobalMode
}
@@ -324,6 +329,7 @@ export class DataContext {
projectApi: this._config.projectApi,
electronApi: this._config.electronApi,
localSettingsApi: this._config.localSettingsApi,
cohortsApi: this._config.cohortsApi,
}
}

View File

@@ -0,0 +1,54 @@
import type { Cohort } from '@packages/types'
import type { DataContext } from '..'
import { WEIGHTED, WEIGHTED_EVEN } from '../util/weightedChoice'
const debug = require('debug')('cypress:data-context:actions:CohortActions')
export interface CohortsApiShape {
getCohorts(): Promise<Record<string, Cohort> | undefined>
getCohort(name: string): Promise<Cohort | undefined>
insertCohort (cohort: Cohort): Promise<void>
}
export class CohortsActions {
constructor (private ctx: DataContext) {}
async getCohorts () {
debug('Getting all cohorts')
return this.ctx._apis.cohortsApi.getCohorts()
}
async getCohort (name: string) {
debug('Getting cohort for %s', name)
return this.ctx._apis.cohortsApi.getCohort(name)
}
async determineCohort (name: string, cohorts: string[], weights?: number[]) {
debug('Determining cohort', name, cohorts)
const cohortFromCache = await this.getCohort(name)
let cohortSelected: Cohort
if (!cohortFromCache || !cohorts.includes(cohortFromCache.cohort)) {
const algorithm = weights ? WEIGHTED(weights) : WEIGHTED_EVEN(cohorts)
const pickedCohort = {
name,
cohort: algorithm.pick(cohorts),
}
debug('Inserting cohort for %o', pickedCohort)
await this.ctx._apis.cohortsApi.insertCohort(pickedCohort)
cohortSelected = pickedCohort
} else {
cohortSelected = cohortFromCache
}
debug('Selecting cohort', cohortSelected)
return cohortSelected
}
}

View File

@@ -4,6 +4,7 @@
export * from './AppActions'
export * from './AuthActions'
export * from './BrowserActions'
export * from './CohortsActions'
export * from './DataEmitterActions'
export * from './DevActions'
export * from './ElectronActions'

View File

@@ -527,6 +527,7 @@ export class ProjectConfigManager {
}
async getFullInitialConfig (options: Partial<AllModeOptions> = this.options.ctx.modeOptions, withBrowsers = true): Promise<FullConfig> {
// return cached configuration for new spec and/or new navigating load when Cypress is running tests
if (this._cachedFullConfig) {
return this._cachedFullConfig
}

View File

@@ -120,10 +120,7 @@ export class HtmlDataSource {
window.__CYPRESS_CONFIG__ = ${JSON.stringify(serveConfig)};
window.__CYPRESS_TESTING_TYPE__ = '${this.ctx.coreData.currentTestingType}'
window.__CYPRESS_BROWSER__ = ${JSON.stringify(this.ctx.coreData.activeBrowser)}
${process.env.CYPRESS_INTERNAL_GQL_NO_SOCKET
? `window.__CYPRESS_GQL_NO_SOCKET__ = 'true';`
: ''
}
${process.env.CYPRESS_INTERNAL_GQL_NO_SOCKET ? `window.__CYPRESS_GQL_NO_SOCKET__ = 'true';` : ''}
</script>
`)
}

View File

@@ -9,3 +9,4 @@ export * from './file'
export * from './hasTypescript'
export * from './pluginHandlers'
export * from './urqlCacheKeys'
export * from './weightedChoice'

View File

@@ -0,0 +1,57 @@
import _ from 'lodash'
export type WeightedAlgorithm = {
pick: (values: string[]) => string
}
/**
* Randomly choose an index from an array based on weights
*
* Based on algorithm found here: https://dev.to/trekhleb/weighted-random-algorithm-in-javascript-1pdc
*
* @param weights array of numbered weights that correspond to the indexed values
* @param values array of values to choose from
*/
const weightedChoice = (weights: number[], values: any[]) => {
if (weights.length === 0 || values.length === 0 || weights.length !== values.length) {
throw new Error('The length of the weights and values must be the same and greater than zero')
}
const cumulativeWeights = weights.reduce<number[]>((acc, curr) => {
if (acc.length === 0) {
return [curr]
}
const last = acc[acc.length - 1]
if (!last) {
return acc
}
return [...acc, last + curr]
}, [])
const randomNumber = Math.random() * (cumulativeWeights[cumulativeWeights.length - 1] ?? 1)
const choice = _.transform(cumulativeWeights, (result, value, index) => {
if (value >= randomNumber) {
result.chosenIndex = index
}
return result.chosenIndex === -1
}, { chosenIndex: -1 })
return values[choice.chosenIndex]
}
export const WEIGHTED = (weights: number[]): WeightedAlgorithm => {
return {
pick: (values: any[]): string => {
return weightedChoice(weights, values)
},
}
}
export const WEIGHTED_EVEN = (values: any[]): WeightedAlgorithm => {
return WEIGHTED(_.fill(Array(values.length), 1))
}

View File

@@ -0,0 +1,57 @@
import type { DataContext } from '../../../src'
import { CohortsActions } from '../../../src/actions/CohortsActions'
import { createTestDataContext } from '../helper'
import { expect } from 'chai'
import sinon, { SinonStub, match } from 'sinon'
describe('CohortsActions', () => {
let ctx: DataContext
let actions: CohortsActions
beforeEach(() => {
sinon.restore()
ctx = createTestDataContext('open')
actions = new CohortsActions(ctx)
})
context('getCohort', () => {
it('should return null if name not found', async () => {
const name = '123'
const cohort = await actions.getCohort(name)
expect(cohort).to.be.undefined
expect(ctx.cohortsApi.getCohort).to.have.been.calledWith(name)
})
it('should return cohort if in cache', async () => {
const cohort = {
name: 'loginBanner',
cohort: 'A',
}
;(ctx._apis.cohortsApi.getCohort as SinonStub).resolves(cohort)
const cohortReturned = await actions.getCohort(cohort.name)
expect(cohortReturned).to.eq(cohort)
expect(ctx.cohortsApi.getCohort).to.have.been.calledWith(cohort.name)
})
})
context('determineCohort', () => {
it('should determine cohort', async () => {
const cohortConfig = {
name: 'loginBanner',
cohorts: ['A', 'B'],
}
const pickedCohort = await actions.determineCohort(cohortConfig.name, cohortConfig.cohorts)
expect(ctx.cohortsApi.insertCohort).to.have.been.calledOnceWith({ name: cohortConfig.name, cohort: match.string })
expect(cohortConfig.cohorts.includes(pickedCohort.cohort)).to.be.true
})
})
})

View File

@@ -8,7 +8,7 @@ import { DataContext, DataContextConfig } from '../../src'
import { graphqlSchema } from '@packages/graphql/src/schema'
import { remoteSchemaWrapped as schemaCloud } from '@packages/graphql/src/stitching/remoteSchemaWrapped'
import type { BrowserApiShape } from '../../src/sources/BrowserDataSource'
import type { AppApiShape, AuthApiShape, ElectronApiShape, LocalSettingsApiShape, ProjectApiShape } from '../../src/actions'
import type { AppApiShape, AuthApiShape, ElectronApiShape, LocalSettingsApiShape, ProjectApiShape, CohortsApiShape } from '../../src/actions'
import sinon from 'sinon'
import { execute, parse } from 'graphql'
import { getOperationName } from '@urql/core'
@@ -63,6 +63,12 @@ export function createTestDataContext (mode: DataContextConfig['mode'] = 'run',
focusActiveBrowserWindow: sinon.stub(),
getBrowsers: sinon.stub().resolves([]),
} as unknown as BrowserApiShape,
cohortsApi: {
getCohorts: sinon.stub().resolves(),
getCohort: sinon.stub().resolves(),
insertCohort: sinon.stub(),
determineCohort: sinon.stub().resolves(),
} as unknown as CohortsApiShape,
})
const origFetch = ctx.util.fetch

View File

@@ -0,0 +1,75 @@
import { expect } from 'chai'
import { WEIGHTED, WEIGHTED_EVEN } from '../../../src/util/weightedChoice'
describe('weightedChoice', () => {
context('WeightedAlgorithm', () => {
it('should error if invalid arguments', () => {
const weights = [25, 75, 45]
const options = ['A', 'B']
const func = () => {
WEIGHTED(weights).pick(options)
}
expect(func).to.throw()
})
it('should error if weights is empty', () => {
const weights = []
const options = ['A', 'B']
const func = () => {
WEIGHTED(weights).pick(options)
}
expect(func).to.throw()
})
it('should error if options is empty', () => {
const weights = [25, 75, 45]
const options = []
const func = () => {
WEIGHTED(weights).pick(options)
}
expect(func).to.throw()
})
it('should return an option', () => {
const weights = [25, 75]
const options = ['A', 'B']
const selected = WEIGHTED(weights).pick(options)
expect(options.includes(selected)).to.be.true
})
})
context('WEIGHTED_EVEN', () => {
it('should return an option', () => {
const options = ['A', 'B']
const selected = WEIGHTED_EVEN(options).pick(options)
expect(options.includes(selected)).to.be.true
})
})
context('randomness', () => {
it('should return values close to supplied weights', () => {
const results = {}
const options = ['A', 'B']
const algorithm = WEIGHTED_EVEN(options)
for (let i = 0; i < 1000; i++) {
const selected = algorithm.pick(options)
results[selected] ? results[selected]++ : results[selected] = 1
}
Object.keys(results).forEach((key) => {
expect(Math.round(results[key] / 100)).to.equal(5)
})
})
})
})

View File

@@ -237,6 +237,7 @@ describe('src/cy/commands/sessions/manager.ts', () => {
validate: undefined,
cookies: null,
localStorage: null,
sessionStorage: null,
hydrated: false,
})
@@ -245,7 +246,7 @@ describe('src/cy/commands/sessions/manager.ts', () => {
})
it('sessions.clearAllSavedSessions()', async () => {
const cypressSpy = cy.stub(CypressInstance, 'backend').callThrough().withArgs('clear:session').resolves(null)
const cypressSpy = cy.stub(CypressInstance, 'backend').callThrough().withArgs('clear:sessions', true).resolves(null)
const sessionsManager = new SessionsManager(CypressInstance, () => {})
const sessionsSpy = cy.stub(sessionsManager, 'clearActiveSessions')
@@ -253,7 +254,7 @@ describe('src/cy/commands/sessions/manager.ts', () => {
await sessionsManager.sessions.clearAllSavedSessions()
expect(sessionsSpy).to.be.calledOnce
expect(cypressSpy).to.be.calledOnceWith('clear:session', null)
expect(cypressSpy).to.be.calledOnceWith('clear:sessions', true)
})
describe('.clearCurrentSessionData()', () => {

View File

@@ -190,7 +190,9 @@ describe('cy.session', { retries: 0 }, () => {
expect(logs[0].get()).to.deep.contain({
name: 'session',
id: sessionGroupId,
renderProps: {
sessionInfo: {
id: 'session-1',
isGlobalSession: false,
status: 'created',
},
})
@@ -229,7 +231,8 @@ describe('cy.session', { retries: 0 }, () => {
expect(sessionInfo).to.deep.eq({
id: 'session-1',
data: {},
isGlobalSession: false,
status: 'created',
})
})
@@ -245,11 +248,13 @@ describe('cy.session', { retries: 0 }, () => {
})
describe('create session with validation flow', () => {
let sessionId
before(() => {
setupTestContext()
cy.log('Creating new session with validation to test against')
cy.session(`session-${Cypress.state('test').id}`, setup, { validate })
sessionId = `session-${Cypress.state('test').id}`
cy.session(sessionId, setup, { validate })
})
// test must be first to run before blank page visit between each test
@@ -267,7 +272,9 @@ describe('cy.session', { retries: 0 }, () => {
expect(logs[0].get()).to.deep.contain({
name: 'session',
id: sessionGroupId,
renderProps: {
sessionInfo: {
id: sessionId,
isGlobalSession: false,
status: 'created',
},
})
@@ -327,7 +334,9 @@ describe('cy.session', { retries: 0 }, () => {
expect(logs[0].get()).to.deep.contain({
name: 'session',
id: sessionGroupId,
renderProps: {
sessionInfo: {
id: `session-${Cypress.state('test').id}`,
isGlobalSession: false,
status: 'failed',
},
})
@@ -382,17 +391,20 @@ describe('cy.session', { retries: 0 }, () => {
})
describe('restores saved session flow', () => {
let sessionId
before(() => {
setupTestContext()
cy.log('Creating new session for test')
cy.session(`session-${Cypress.state('test').id}`, setup)
sessionId = `session-${Cypress.state('test').id}`
cy.session(sessionId, setup)
.then(() => {
// reset and only test restored session
resetMocks()
})
cy.log('restore session to test against')
cy.session(`session-${Cypress.state('test').id}`, setup)
cy.session(sessionId, setup)
})
// test must be first to run before blank page visit between each test
@@ -415,7 +427,9 @@ describe('cy.session', { retries: 0 }, () => {
expect(logs[0].get()).to.deep.contain({
name: 'session',
id: sessionGroupId,
renderProps: {
sessionInfo: {
id: sessionId,
isGlobalSession: false,
status: 'restored',
},
})
@@ -440,17 +454,20 @@ describe('cy.session', { retries: 0 }, () => {
})
describe('restores saved session with validation flow', () => {
let sessionId
before(() => {
setupTestContext()
cy.log('Creating new session for test')
cy.session(`session-${Cypress.state('test').id}`, setup, { validate })
sessionId = `session-${Cypress.state('test').id}`
cy.session(sessionId, setup, { validate })
.then(() => {
// reset and only test restored session
resetMocks()
})
cy.log('restore session to test against')
cy.session(`session-${Cypress.state('test').id}`, setup, { validate })
cy.session(sessionId, setup, { validate })
})
// test must be first to run before blank page visit between each test
@@ -473,7 +490,9 @@ describe('cy.session', { retries: 0 }, () => {
expect(logs[0].get()).to.deep.contain({
name: 'session',
id: sessionGroupId,
renderProps: {
sessionInfo: {
id: sessionId,
isGlobalSession: false,
status: 'restored',
},
})
@@ -510,11 +529,13 @@ describe('cy.session', { retries: 0 }, () => {
})
describe('recreates existing session flow', () => {
let sessionId
before(() => {
setupTestContext()
cy.log('Creating new session for test')
cy.session(`session-${Cypress.state('test').id}`, setup, { validate })
sessionId = `session-${Cypress.state('test').id}`
cy.session(sessionId, setup, { validate })
.then(() => {
// reset and only test restored session
resetMocks()
@@ -528,7 +549,7 @@ describe('cy.session', { retries: 0 }, () => {
})
cy.log('restore session to test against')
cy.session(`session-${Cypress.state('test').id}`, setup, { validate })
cy.session(sessionId, setup, { validate })
})
// test must be first to run before blank page visit between each test
@@ -551,7 +572,9 @@ describe('cy.session', { retries: 0 }, () => {
expect(logs[0].get()).to.deep.contain({
name: 'session',
id: sessionGroupId,
renderProps: {
sessionInfo: {
id: sessionId,
isGlobalSession: false,
status: 'recreated',
},
})
@@ -659,7 +682,9 @@ describe('cy.session', { retries: 0 }, () => {
expect(logs[0].get()).to.deep.contain({
name: 'session',
id: sessionGroupId,
renderProps: {
sessionInfo: {
id: `session-${Cypress.state('test').id}`,
isGlobalSession: false,
status: 'failed',
},
})

View File

@@ -1,110 +1,9 @@
const {
getSessionDetails,
getConsoleProps,
navigateAboutBlank,
} = require('@packages/driver/src/cy/commands/sessions/utils')
describe('src/cy/commands/sessions/utils.ts', () => {
describe('.getSessionDetails', () => {
it('for one domain with neither cookies or local storage set', () => {
const sessionState = {
id: 'session1',
}
const details = getSessionDetails(sessionState)
expect(details.id).to.eq('session1')
expect(Object.keys(details.data)).to.have.length(0)
})
it('for one domain with only cookies set', () => {
const sessionState = {
id: 'session1',
cookies: [
{ name: 'foo', value: 'f', path: '/', domain: 'localhost', secure: true, httpOnly: true, expiry: 123 },
],
}
const details = getSessionDetails(sessionState)
expect(details.id).to.eq('session1')
expect(Object.keys(details.data)).to.have.length(1)
expect(details.data).to.have.property('localhost')
expect(details.data.localhost).to.deep.eq({
cookies: 1,
})
})
it('for one domain with only local storage set', () => {
const sessionState = {
id: 'session1',
localStorage: [
{ origin: 'localhost', value: { 'stor-foo': 's-f' } },
],
}
const details = getSessionDetails(sessionState)
expect(details.id).to.eq('session1')
expect(Object.keys(details.data)).to.have.length(1)
expect(details.data).to.have.property('localhost')
expect(details.data.localhost).to.deep.eq({
localStorage: 1,
})
})
it('for one domain with both cookies and localStorage', () => {
const sessionState = {
id: 'session1',
cookies: [
{ name: 'foo', value: 'f', path: '/', domain: 'localhost', secure: true, httpOnly: true, expiry: 123 },
],
localStorage: [
{ origin: 'localhost', value: { 'stor-foo': 's-f' } },
],
}
const details = getSessionDetails(sessionState)
expect(details.id).to.eq('session1')
expect(Object.keys(details.data)).to.have.length(1)
expect(details.data).to.have.property('localhost')
expect(details.data.localhost).to.deep.eq({
cookies: 1,
localStorage: 1,
})
})
it('for multiple domains', () => {
const sessionState = {
id: 'session1',
cookies: [
{ 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 },
],
localStorage: [
{ origin: 'localhost', value: { 'stor-foo': 's-f' } },
{ origin: 'http://example.com', value: { 'random': 'hi' } },
],
}
const details = getSessionDetails(sessionState)
expect(details.id).to.eq('session1')
expect(Object.keys(details.data)).to.have.length(2)
expect(details.data).to.have.property('localhost')
expect(details.data.localhost).to.deep.eq({
cookies: 2,
localStorage: 1,
})
expect(details.data).to.have.property('example.com')
expect(details.data['example.com']).to.deep.eq({
localStorage: 1,
})
})
})
describe('.getConsoleProps', () => {
it('for one domain with neither cookies or localStorage set', () => {
const sessionState = {

View File

@@ -10,6 +10,8 @@ import { bothUrlsMatchAndOneHasHash } from '../navigation'
import { $Location, LocationObject } from '../../cypress/location'
import { isRunnerAbleToCommunicateWithAut } from '../../util/commandAUTCommunication'
import type { RunState } from '@packages/types'
import debugFn from 'debug'
const debug = debugFn('cypress:driver:navigation')
@@ -1116,26 +1118,26 @@ export default (Commands, Cypress, cy, state, config) => {
// tell our backend we're changing origins
// TODO: add in other things we want to preserve
// state for like scrollTop
let s: Record<string, any> = {
let runState: RunState = {
currentId: id,
tests: Cypress.runner.getTestsState(),
startTime: Cypress.runner.getStartTime(),
emissions: Cypress.runner.getEmissions(),
}
s.passed = Cypress.runner.countByTestState(s.tests, 'passed')
s.failed = Cypress.runner.countByTestState(s.tests, 'failed')
s.pending = Cypress.runner.countByTestState(s.tests, 'pending')
s.numLogs = LogUtils.countLogsByTests(s.tests)
runState.passed = Cypress.runner.countByTestState(runState.tests, 'passed')
runState.failed = Cypress.runner.countByTestState(runState.tests, 'failed')
runState.pending = Cypress.runner.countByTestState(runState.tests, 'pending')
runState.numLogs = LogUtils.countLogsByTests(runState.tests)
return Cypress.action('cy:collect:run:state')
.then((a = []) => {
.then((otherRunStates = []) => {
// merge all the states together holla'
s = _.reduce(a, (memo, obj) => {
runState = _.reduce(otherRunStates, (memo, obj) => {
return _.extend(memo, obj)
}, s)
}, runState)
return Cypress.backend('preserve:run:state', s)
return Cypress.backend('preserve:run:state', runState)
})
.then(() => {
// and now we must change the url to be the new

View File

@@ -5,7 +5,6 @@ import $stackUtils from '../../../cypress/stack_utils'
import logGroup from '../../logGroup'
import SessionsManager from './manager'
import {
getSessionDetails,
getConsoleProps,
navigateAboutBlank,
} from './utils'
@@ -118,6 +117,16 @@ export default function (Commands, Cypress, cy) {
}
}
function setSessionLogStatus (status: string) {
_log.set({
sessionInfo: {
id: existingSession.id,
isGlobalSession: false,
status,
},
})
}
function createSession (existingSession, recreateSession = false) {
logGroup(Cypress, {
name: 'session',
@@ -178,7 +187,8 @@ export default function (Commands, Cypress, cy) {
const onFail = (err) => {
validateLog.set({ state: 'failed' })
_log.set({ renderProps: { status: 'failed' } })
setSessionLogStatus('failed')
// show validation error and allow sessions workflow to recreate the session
if (restoreSession) {
Cypress.log({
@@ -296,7 +306,8 @@ export default function (Commands, Cypress, cy) {
*/
const createSessionWorkflow = (existingSession, recreateSession = false) => {
return cy.then(async () => {
_log.set({ renderProps: { status: recreateSession ? 'recreating' : 'creating' } })
setSessionLogStatus(recreateSession ? 'recreating' : 'creating')
await navigateAboutBlank()
await sessions.clearCurrentSessionData()
@@ -308,7 +319,7 @@ export default function (Commands, Cypress, cy) {
return
}
_log.set({ renderProps: { status: recreateSession ? 'recreated' : 'created' } })
setSessionLogStatus(recreateSession ? 'recreated' : 'created')
})
}
@@ -320,7 +331,7 @@ export default function (Commands, Cypress, cy) {
*/
const restoreSessionWorkflow = (existingSession) => {
return cy.then(async () => {
_log.set({ renderProps: { status: 'restoring' } })
setSessionLogStatus('restoring')
await navigateAboutBlank()
await sessions.clearCurrentSessionData()
@@ -332,7 +343,7 @@ export default function (Commands, Cypress, cy) {
return createSessionWorkflow(existingSession, true)
}
_log.set({ renderProps: { status: 'restored' } })
setSessionLogStatus('restored')
})
}
@@ -349,7 +360,6 @@ export default function (Commands, Cypress, cy) {
let _log
const groupDetails = {
message: `${existingSession.id.length > 50 ? `${existingSession.id.substring(0, 47)}...` : existingSession.id}`,
sessionInfo: getSessionDetails(existingSession),
}
return logGroup(Cypress, groupDetails, (log) => {

View File

@@ -1,6 +1,6 @@
import _ from 'lodash'
import { $Location } from '../../../cypress/location'
import type { ServerSessionData } from '@packages/types'
import {
getCurrentOriginStorage,
setPostMessageLocalStorage,
@@ -120,6 +120,7 @@ export default class SessionsManager {
id: options.id,
cookies: null,
localStorage: null,
sessionStorage: null,
setup: options.setup,
hydrated: false,
validate: options.validate,
@@ -132,8 +133,9 @@ export default class SessionsManager {
clearAllSavedSessions: async () => {
this.clearActiveSessions()
const clearAllSessions = true
return this.Cypress.backend('clear:session', null)
return this.Cypress.backend('clear:sessions', clearAllSessions)
},
clearCurrentSessionData: async () => {
@@ -205,7 +207,7 @@ export default class SessionsManager {
}
},
getSession: (id: string) => {
getSession: (id: string): Promise<ServerSessionData> => {
return this.Cypress.backend('get:session', id)
},

View File

@@ -1,19 +1,10 @@
import _ from 'lodash'
import $ from 'jquery'
import { $Location } from '../../../cypress/location'
import Bluebird from 'bluebird'
import { $Location } from '../../../cypress/location'
type SessionData = Cypress.Commands.Session.SessionData
const getSessionDetails = (sessState: SessionData) => {
return {
id: sessState.id,
data: _.merge(
_.mapValues(_.groupBy(sessState.cookies, 'domain'), (v) => ({ cookies: v.length })),
..._.map(sessState.localStorage, (v) => ({ [$Location.create(v.origin).hostname]: { localStorage: Object.keys(v.value).length } })),
) }
}
const getSessionDetailsForTable = (sessState: SessionData) => {
return _.merge(
_.mapValues(_.groupBy(sessState.cookies, 'domain'), (v) => ({ cookies: v })),
@@ -199,7 +190,6 @@ function navigateAboutBlank (session: boolean = true) {
}
export {
getSessionDetails,
getCurrentOriginStorage,
setPostMessageLocalStorage,
getConsoleProps,

View File

@@ -42,6 +42,8 @@ import * as resolvers from './cypress/resolvers'
import { PrimaryOriginCommunicator, SpecBridgeCommunicator } from './cross-origin/communicator'
import { setupAutEventHandlers } from './cypress/aut_event_handlers'
import type { CachedTestState } from '@packages/types'
const debug = debugFn('cypress:driver:cypress')
declare global {
@@ -280,11 +282,13 @@ class $Cypress {
}
}
run (fn) {
run (cachedTestState: CachedTestState, fn) {
if (!this.runner) {
$errUtils.throwErrByPath('miscellaneous.no_runner')
}
this.state(cachedTestState)
return this.runner.run(fn)
}

View File

@@ -15,7 +15,7 @@ import type { StateFunc } from './state'
const groupsOrTableRe = /^(groups|table)$/
const parentOrChildRe = /parent|child|system/
const SNAPSHOT_PROPS = 'id snapshots $el url coords highlightAttr scrollBy viewportWidth viewportHeight'.split(' ')
const DISPLAY_PROPS = 'id alias aliasType callCount displayName end err event functionName groupLevel hookId instrument isStubbed group message method name numElements showError numResponses referencesAlias renderProps state testId timeout type url visible wallClockStartedAt testCurrentRetry'.split(' ')
const DISPLAY_PROPS = 'id alias aliasType callCount displayName end err event functionName groupLevel hookId instrument isStubbed group message method name numElements showError numResponses referencesAlias renderProps sessionInfo state testId timeout type url visible wallClockStartedAt testCurrentRetry'.split(' ')
const BLACKLIST_PROPS = 'snapshots'.split(' ')
let counter = 0
@@ -642,10 +642,6 @@ class LogManager {
this.addToLogs(log)
if (options.sessionInfo) {
Cypress.emit('session:add', log.toJSON())
}
if (options.emitOnly) {
return
}

View File

@@ -6,7 +6,7 @@ declare namespace Cypress {
type SessionSetup = (log: Cypress.Log) => Chainable<S>
type SessionValidation = (log: Cypress.Log) => Chainable<S>
interface LocalStorage {
interface Storage {
origin: string
value: Record<string, any>
}
@@ -14,7 +14,8 @@ declare namespace Cypress {
interface SessionData {
id: string
cookies?: Array<Cypress.Cookie> | null
localStorage?: Array<LocalStorage> | null
localStorage?: Array<Storage> | null
sessionStorage?: Array<Storage> | null
setup: () => void
hydrated: boolean
validate?: Cypress.SessionOptions['validate']

View File

@@ -17,14 +17,6 @@ declare namespace Cypress {
displayName?: string
// additional information to include in the log
message?: string
// session information to associate with the log and be added to the session instrument panel
sessionInfo?: {
id: string
data: {
cookies?: Array<Cypress.Cookie> | null
localStorage?: Array<LocalStorage> | null
}
}
// timeout of the group command - defaults to defaultCommandTimeout
timeout?: number
// the type of log

View File

@@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.85634 2.4375L9.48916 2.77687L9.85634 2.4375ZM2.14366 2.4375L2.51085 2.77687L2.14366 2.4375ZM2.14366 9.5625L2.51085 9.22313L2.14366 9.5625ZM9.85634 9.5625L9.48916 9.22313L9.85634 9.5625ZM7.37726 2.43747L6.90246 2.5942V2.5942L7.37726 2.43747ZM4.62273 2.43747L5.09753 2.5942L4.62273 2.43747ZM4.62273 9.56247L4.14793 9.7192L4.62273 9.56247ZM7.37726 9.56247L7.85206 9.7192L7.37726 9.56247ZM11.25 5.97854L11.75 5.97654L11.25 5.97854ZM0.750043 5.97855L0.250047 5.97654L0.750043 5.97855ZM6 1.25C7.37931 1.25 8.62071 1.83726 9.48916 2.77687L10.2235 2.09813C9.17374 0.962306 7.66968 0.25 6 0.25V1.25ZM2.51085 2.77687C3.37929 1.83726 4.62069 1.25 6 1.25V0.25C4.33032 0.25 2.82626 0.962306 1.77647 2.09813L2.51085 2.77687ZM0.25 6C0.25 7.50569 0.82939 8.87718 1.77648 9.90188L2.51085 9.22313C1.7278 8.37591 1.25 7.24432 1.25 6H0.25ZM6 10.75C4.62069 10.75 3.37929 10.1627 2.51085 9.22313L1.77648 9.90188C2.82626 11.0377 4.33032 11.75 6 11.75V10.75ZM6 11.75C7.66968 11.75 9.17374 11.0377 10.2235 9.90188L9.48916 9.22313C8.62071 10.1627 7.37931 10.75 6 10.75V11.75ZM10.75 6C10.75 7.24432 10.2722 8.37591 9.48916 9.22313L10.2235 9.90188C11.1706 8.87718 11.75 7.50569 11.75 6H10.75ZM8.375 5.99997C8.375 4.58437 8.18126 3.27803 7.85206 2.28074L6.90246 2.5942C7.19117 3.46883 7.375 4.66556 7.375 5.99997H8.375ZM6 1.25C6.05735 1.25 6.18737 1.28347 6.37373 1.51671C6.55646 1.74539 6.74142 2.10635 6.90246 2.5942L7.85206 2.28074C7.67055 1.73087 7.43912 1.2481 7.15497 0.892474C6.87445 0.541404 6.48711 0.25 6 0.25V1.25ZM4.14793 2.28074C3.81873 3.27803 3.62499 4.58437 3.62499 5.99997H4.62499C4.62499 4.66556 4.80882 3.46883 5.09753 2.5942L4.14793 2.28074ZM5.09753 2.5942C5.25857 2.10635 5.44354 1.74539 5.62626 1.51671C5.81263 1.28347 5.94265 1.25 6 1.25V0.25C5.51289 0.25 5.12555 0.541402 4.84503 0.892471C4.56087 1.24809 4.32944 1.73087 4.14793 2.28074L5.09753 2.5942ZM6 10.75C5.94267 10.75 5.81265 10.7165 5.62627 10.4833C5.44354 10.2546 5.25857 9.8936 5.09753 9.40574L4.14793 9.7192C4.32944 10.2691 4.56087 10.7519 4.84502 11.1075C5.12553 11.4586 5.51288 11.75 6 11.75V10.75ZM6 11.75C6.48713 11.75 6.87447 11.4586 7.15498 11.1075C7.43913 10.7519 7.67056 10.2691 7.85206 9.7192L6.90246 9.40575C6.74142 9.8936 6.55645 10.2546 6.37372 10.4833C6.18735 10.7165 6.05734 10.75 6 10.75V11.75ZM11.75 6C11.75 5.99218 11.75 5.98436 11.75 5.97654L10.75 5.98055C10.75 5.98703 10.75 5.99351 10.75 6H11.75ZM11.75 5.97654C11.744 4.48004 11.1657 3.11748 10.2235 2.09813L9.48916 2.77687C10.2681 3.61969 10.745 4.74393 10.75 5.98055L11.75 5.97654ZM1.77647 2.09813C0.834327 3.11748 0.256034 4.48004 0.250047 5.97654L1.25004 5.98055C1.25499 4.74393 1.73186 3.61969 2.51085 2.77687L1.77647 2.09813ZM0.250047 5.97654C0.250016 5.98436 0.25 5.99218 0.25 6H1.25C1.25 5.99351 1.25001 5.98703 1.25004 5.98055L0.250047 5.97654ZM3.62499 5.99997C3.62499 6.23231 3.63021 6.46138 3.64035 6.68634L4.63934 6.64132C4.62988 6.43143 4.62499 6.21737 4.62499 5.99997H3.62499ZM4.09291 7.16162C4.70657 7.21947 5.34489 7.24997 6 7.24997V6.24997C5.37577 6.24997 4.76877 6.2209 4.18677 6.16604L4.09291 7.16162ZM6 7.24997C6.6551 7.24997 7.29343 7.21947 7.90708 7.16162L7.81322 6.16604C7.23122 6.2209 6.62422 6.24997 6 6.24997V7.24997ZM8.35964 6.68634C8.36978 6.46138 8.375 6.23231 8.375 5.99997H7.375C7.375 6.21737 7.37011 6.43143 7.36066 6.64132L8.35964 6.68634ZM7.90708 7.16162C9.20524 7.03924 10.3987 6.7939 11.41 6.45224L11.0899 5.50485C10.1634 5.81789 9.04736 6.04969 7.81322 6.16604L7.90708 7.16162ZM7.36066 6.64132C7.31212 7.71841 7.14355 8.67536 6.90246 9.40575L7.85206 9.7192C8.12782 8.8838 8.30795 7.83337 8.35964 6.68634L7.36066 6.64132ZM4.18677 6.16604C2.95264 6.04969 1.83665 5.81789 0.91008 5.50485L0.590006 6.45224C1.60127 6.79389 2.79476 7.03924 4.09291 7.16162L4.18677 6.16604ZM3.64035 6.68634C3.69204 7.83337 3.87217 8.8838 4.14793 9.7192L5.09753 9.40574C4.85644 8.67536 4.68787 7.71841 4.63934 6.64132L3.64035 6.68634ZM0.250044 5.97755L0.250001 5.999L1.25 6.001L1.25004 5.97955L0.250044 5.97755ZM11.75 5.999L11.75 5.97754L10.75 5.97954L10.75 6.001L11.75 5.999Z" fill="#9AA2FC"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,31 @@
import UseCohortsExample, { CopyOption } from './UseCohortsExample.vue'
import { UseCohorts_DetermineCohortDocument } from '../../generated/graphql'
describe('useCohorts example', () => {
const copyOptions: CopyOption[] = [
{ cohort: 'A', value: 'Notification Title A' },
{ cohort: 'B', value: 'Notification Title B' },
]
beforeEach(() => {
cy.stubMutationResolver(UseCohorts_DetermineCohortDocument, (defineResult) => {
return defineResult({ determineCohort: { __typename: 'Cohort', name: 'foo', cohort: 'A' } })
})
})
it('should show value for one cohort with default algorithm', () => {
cy.mount(() => <UseCohortsExample copyOptions={copyOptions}/>)
cy.findByTestId('result').then((elem) => {
expect(copyOptions.map((option) => option.value)).to.include(elem.text())
})
})
it('should show value for one cohort with supplied algorithm', () => {
const weighted25_75 = [25, 75]
cy.mount(() => <UseCohortsExample copyOptions={copyOptions} weights={weighted25_75}/>)
cy.findByTestId('result').then((elem) => {
expect(copyOptions.map((option) => option.value)).to.include(elem.text())
})
})
})

View File

@@ -0,0 +1,30 @@
<template>
<div data-cy="result">
{{ cohortChoice?.value }}
</div>
</template>
<script lang="ts">
export type CopyOption = {
cohort: string
value: string
}
</script>
<script setup lang="ts">
import { CohortConfig, useCohorts } from '../useCohorts'
const props = defineProps<{
weights?: number[]
copyOptions: CopyOption[]
}>()
const cohortConfig: CohortConfig = {
name: 'login',
options: props.copyOptions,
weights: props.weights,
}
const cohortChoice = useCohorts().getCohort(cohortConfig)
</script>

View File

@@ -0,0 +1,83 @@
import { useMutation, gql } from '@urql/vue'
import { UseCohorts_DetermineCohortDocument } from '../generated/graphql'
import { ref } from 'vue'
gql`
mutation UseCohorts_DetermineCohort ($name: String!, $cohorts: [String!]!) {
determineCohort(cohortConfig: { name: $name, cohorts: $cohorts } ) {
__typename
name
cohort
}
}`
/**
* An option to use for a given cohort selection.
*/
export type CohortOption = {
/** The individual cohort identifier. Example: 'A' or 'B' */
cohort: string
/** The value to be used by the calling code for the given cohort. The algorithm for selecting the cohort does not care about this value, but it will return the entire CohortOption that is selected so that this value can be used by the calling code. */
value: any
}
/**
* The configuration of the cohort to be used to select a cohort.
*/
export type CohortConfig = {
/** The name of the feature the cohort will be calculated for. This will be used as a key in the cache file for storing the selected option. */
name: string
/** Array of options to pick from when selecting the cohort */
options: CohortOption[]
/** Optional array of weights to use for selecting the cohort. If not supplied, an even weighting algorithm will be used. */
weights?: number[]
}
/**
* Composable that encapsulates the logic for choosing a cohort from a list of configured options.
*
* @remarks
* The logic for this composable will first check the cache file to determine if a cohort has already been saved for the given cohort `name`. If found, that cohort will be returned. If not found or the option found does not match an existing option, a weighted algorithm will be used to pick from the list of CohortOptions. The picked value will be stored in the cache and returned.
*
* @returns object with getCohort function for returning the cohort
*/
export const useCohorts = () => {
const determineCohortMutation = useMutation(UseCohorts_DetermineCohortDocument)
const determineCohort = async (name: string, cohorts: string[]) => {
return await determineCohortMutation.executeMutation({
name,
cohorts,
})
}
/**
* Return the cohort from the list of configured options
*
* @param config - cohort configuration that contains the options to choose from and optionally the algorithm to use. Defaults to using the WEIGHTED_EVEN algorithm
*
* @returns a reactive reference to the cohort option that is selected
*/
const getCohort = (config: CohortConfig) => {
const cohortOptionSelected = ref<CohortOption>()
const cohortIds = config.options.map((option) => option.cohort)
const fetchCohort = async () => {
const cohortSelected = await determineCohort(config.name, cohortIds)
cohortOptionSelected.value = config.options.find((option) => option.cohort === cohortSelected.data?.determineCohort?.cohort)
}
fetchCohort()
return cohortOptionSelected
}
return {
getCohort,
}
}

View File

@@ -197,17 +197,20 @@
"banners": {
"login": {
"title": "Optimize and record your CI test runs with Cypress Dashboard",
"content": "Parallelize your tests in CI and visualize every error by watching full video recordings of each test you run.",
"contentA": "Parallelize your tests in CI and visualize every error by watching full video recordings of each test you run.",
"contentB": "When you configure Cypress to record tests to the Cypress Dashboard, you'll see data from your latest recorded runs in the Cypress app. This increased visibility into your test history allows you to debug your tests faster and more effectively, all within your local workflow.",
"buttonLabel": "Get started with Cypress Dashboard"
},
"createOrganization": {
"title": "Finish setting up Cypress Dashboard",
"titleA": "Finish setting up Cypress Dashboard",
"titleB": "Create or join an organization",
"content": "Join or create an organization in Cypress Dashboard to access your projects and recorded test runs.",
"buttonLabel": "Set up your organization"
},
"connectProject": {
"title": "Connect your project to Cypress Dashboard",
"content": "View recorded test runs directly in the Cypress app to monitor, run, and fix tests locally.",
"contentA": "View recorded test runs directly in the Cypress app to monitor, run, and fix tests locally.",
"contentB": "Bring your recorded test results into your local development workflow to monitor, run, and fix tests all in the Cypress app.",
"buttonLabel": "Connect your project"
},
"record": {

View File

@@ -555,6 +555,28 @@ enum CodeLanguageEnum {
ts
}
"""used to distinguish one group of users from another"""
type Cohort {
"""value used to indicate the cohort (e.g. "A" or "B")"""
cohort: String!
"""name used to identify the cohort topic (e.g. "LoginBanner" ) """
name: String!
}
input CohortInput {
"""Array of cohort options to choose from. Ex: A or B """
cohorts: [String!]!
"""Name of the cohort"""
name: String!
"""
Optional array of integer weights to use for determining cohort. Defaults to even weighting
"""
weights: [Int!]
}
"""
The currently opened Cypress project, represented by a cypress.config.{js,ts,mjs,cjs} file
"""
@@ -1198,6 +1220,11 @@ type Mutation {
"""add the passed text to the local clipboard"""
copyTextToClipboard(text: String!): Boolean
"""
Determine the cohort based on the given configuration. This will either return the cached cohort for a given name or choose a new one and store it.
"""
determineCohort(cohortConfig: CohortInput!): Cohort
"""
Development only: Triggers or dismisses a prompted refresh by touching the file watched by our development scripts
"""
@@ -1494,6 +1521,12 @@ type Query {
"""A user within the Cypress Cloud"""
cloudViewer: CloudUser
"""Return the cohort for the given name"""
cohort(
"""the name of the cohort to find"""
name: String!
): Cohort
"""The currently opened project"""
currentProject: CurrentProject

View File

@@ -0,0 +1,19 @@
import { inputObjectType, objectType } from 'nexus'
export const Cohort = objectType({
name: 'Cohort',
description: 'used to distinguish one group of users from another',
definition (t) {
t.nonNull.string('name', { description: 'name used to identify the cohort topic (e.g. "LoginBanner" ) ' })
t.nonNull.string('cohort', { description: 'value used to indicate the cohort (e.g. "A" or "B")' })
},
})
export const CohortInput = inputObjectType({
name: 'CohortInput',
definition (t) {
t.nonNull.string('name', { description: 'Name of the cohort' })
t.nonNull.list.nonNull.string('cohorts', { description: 'Array of cohort options to choose from. Ex: A or B ' })
t.list.nonNull.int('weights', { description: 'Optional array of integer weights to use for determining cohort. Defaults to even weighting' })
},
})

View File

@@ -7,6 +7,7 @@ import { FileDetailsInput } from '../inputTypes/gql-FileDetailsInput'
import { WizardUpdateInput } from '../inputTypes/gql-WizardUpdateInput'
import { CurrentProject } from './gql-CurrentProject'
import { GenerateSpecResponse } from './gql-GenerateSpecResponse'
import { Cohort, CohortInput } from './gql-Cohorts'
import { Query } from './gql-Query'
import { ScaffoldedFile } from './gql-ScaffoldedFile'
import { WIZARD_BUNDLERS, WIZARD_FRAMEWORKS } from '@packages/scaffold-config'
@@ -684,6 +685,17 @@ export const mutation = mutationType({
},
})
t.field('determineCohort', {
type: Cohort,
description: 'Determine the cohort based on the given configuration. This will either return the cached cohort for a given name or choose a new one and store it.',
args: {
cohortConfig: nonNull(CohortInput),
},
resolve: async (source, args, ctx) => {
return ctx.actions.cohorts.determineCohort(args.cohortConfig.name, args.cohortConfig.cohorts, args.cohortConfig.weights || undefined)
},
})
t.field('recordEvent', {
type: 'Boolean',
description: 'Dispatch an event to the dashboard to be recorded. Events are completely anonymous and are only used to identify aggregate usage patterns across all Cypress users.',

View File

@@ -1,4 +1,4 @@
import { idArg, nonNull, objectType } from 'nexus'
import { idArg, stringArg, nonNull, objectType } from 'nexus'
import { ProjectLike, ScaffoldedFile } from '..'
import { CurrentProject } from './gql-CurrentProject'
import { DevState } from './gql-DevState'
@@ -9,6 +9,7 @@ import { VersionData } from './gql-VersionData'
import { Wizard } from './gql-Wizard'
import { ErrorWrapper } from './gql-ErrorWrapper'
import { CachedUser } from './gql-CachedUser'
import { Cohort } from './gql-Cohorts'
export const Query = objectType({
name: 'Query',
@@ -107,6 +108,17 @@ export const Query = objectType({
resolve: (source, args, ctx) => Boolean(ctx.modeOptions.invokedFromCli),
})
t.field('cohort', {
description: 'Return the cohort for the given name',
type: Cohort,
args: {
name: nonNull(stringArg({ description: 'the name of the cohort to find' })),
},
resolve: async (source, args, ctx) => {
return await ctx.cohortsApi.getCohort(args.name) ?? null
},
})
t.field('node', {
type: 'Node',
args: {

View File

@@ -6,6 +6,7 @@ export * from './gql-Browser'
export * from './gql-CachedUser'
export * from './gql-CodeFrame'
export * from './gql-CodeGenGlobs'
export * from './gql-Cohorts'
export * from './gql-CurrentProject'
export * from './gql-DevState'
export * from './gql-Editor'

View File

@@ -36,8 +36,8 @@ const debug = null
// https://github.com/cypress-io/cypress/issues/1756
const zlibOptions = {
flush: zlib.Z_SYNC_FLUSH,
finishFlush: zlib.Z_SYNC_FLUSH,
flush: zlib.constants.Z_SYNC_FLUSH,
finishFlush: zlib.constants.Z_SYNC_FLUSH,
}
// https://github.com/cypress-io/cypress/issues/1543

View File

@@ -94,6 +94,11 @@ export default class Attempt {
addLog = (props: LogProps) => {
switch (props.instrument) {
case 'command': {
// @ts-ignore satisfied by CommandProps
if (props.sessionInfo) {
this._addSession(props as unknown as SessionProps) // add sessionInstrumentPanel details
}
return this._addCommand(props as CommandProps)
}
case 'agent': {
@@ -112,6 +117,11 @@ export default class Attempt {
const log = this._logs[props.id]
if (log) {
// @ts-ignore satisfied by CommandProps
if (props.sessionInfo) {
this._updateOrAddSession(props as unknown as SessionProps) // update sessionInstrumentPanel details
}
log.update(props)
}
}
@@ -177,7 +187,19 @@ export default class Attempt {
_addSession (props: SessionProps) {
const session = new Session(props)
this.sessions[props.sessionInfo.id] = session
this.sessions[props.id] = session
}
_updateOrAddSession (props: SessionProps) {
const session = this.sessions[props.id]
if (session) {
session.update(props)
return
}
this._addSession(props)
}
_addRoute (props: RouteProps) {

View File

@@ -119,7 +119,6 @@
}
}
.collapsible-indicator,
.collapsible-more {
display: none;
}

View File

@@ -3,6 +3,10 @@
margin-right: 8px;
transform: rotate(-90deg);
transition: transform 150ms ease-out;
.icon-dark {
stroke: $gray-800;
}
}
.is-open > .collapsible-header-wrapper > .collapsible-header > .collapsible-header-inner > .collapsible-indicator {

View File

@@ -4,9 +4,13 @@ import { action, computed, observable } from 'mobx'
import Err, { ErrProps } from '../errors/err-model'
import Instrument, { InstrumentProps } from '../instruments/instrument-model'
import type { TimeoutID } from '../lib/types'
import { SessionProps } from '../sessions/sessions-model'
const LONG_RUNNING_THRESHOLD = 1000
type InterceptStatuses = 'req modified' | 'req + res modified' | 'res modified'
type XHRStatuses = '---' | '(canceled)' | '(aborted)' | string // string = any xhr status
export interface RenderProps {
message?: string
indicator?: 'successful' | 'pending' | 'aborted' | 'bad'
@@ -15,20 +19,17 @@ export interface RenderProps {
alias?: string
type: 'function' | 'stub' | 'spy'
}>
status?: string
status?: InterceptStatuses | XHRStatuses
wentToOrigin?: boolean
}
export interface SessionRenderProps {
status: 'creating' | 'created' | 'restored' |'restored' | 'recreating' | 'recreated' | 'failed'
}
export interface CommandProps extends InstrumentProps {
err?: ErrProps
event?: boolean
number?: number
numElements: number
renderProps?: RenderProps | SessionRenderProps
renderProps?: RenderProps
sessionInfo?: SessionProps['sessionInfo']
timeout?: number
visible?: boolean
wallClockStartedAt?: string
@@ -43,6 +44,7 @@ export interface CommandProps extends InstrumentProps {
export default class Command extends Instrument {
@observable.struct renderProps: RenderProps = {}
@observable.struct sessionInfo?: SessionProps['sessionInfo']
@observable err = new Err({})
@observable event?: boolean = false
@observable isLongRunning = false
@@ -126,6 +128,7 @@ export default class Command extends Instrument {
this.number = props.number
this.numElements = props.numElements
this.renderProps = props.renderProps || {}
this.sessionInfo = props.sessionInfo
this.timeout = props.timeout
// command log that are not associated with elements will not have a visibility
// attribute set. i.e. cy.visit(), cy.readFile() or cy.log()
@@ -149,6 +152,7 @@ export default class Command extends Instrument {
this.event = props.event
this.numElements = props.numElements
this.renderProps = props.renderProps || {}
this.sessionInfo = props.sessionInfo
// command log that are not associated with elements will not have a visibility
// attribute set. i.e. cy.visit(), cy.readFile() or cy.log()
this.visible = props.visible === undefined || props.visible

View File

@@ -351,8 +351,8 @@ class Command extends Component<Props> {
)}
{isSessionCommand && (
<Tag
content={model.renderProps.status}
type={`${model.renderProps.status === 'failed' ? 'failed' : 'successful'}-status`}
content={model.sessionInfo?.status}
type={`${model.sessionInfo?.status === 'failed' ? 'failed' : 'successful'}-status`}
/>
)}
{!model.visible && (

View File

@@ -3,12 +3,7 @@ import { action, computed, observable } from 'mobx'
import { TestState } from '../test/test-model'
import { IntervalID } from '../lib/types'
export interface StatsStoreStartInfo {
startTime: string
numPassed?: number
numFailed?: number
numPending?: number
}
import type { StatsStoreStartInfo } from '@packages/types'
const defaults = {
numPassed: 0,

View File

@@ -7,7 +7,7 @@
border-right: 1px solid $reporter-section-background;
margin-bottom: 10px;
overflow: auto;
padding: 3px 10px;
padding: 0 2px 0 12px;
}
.instrument-content h3,

View File

@@ -2,10 +2,11 @@ import { EventEmitter } from 'events'
import { action } from 'mobx'
import appState, { AppState } from './app-state'
import runnablesStore, { RunnablesStore, RootRunnable, LogProps } from '../runnables/runnables-store'
import statsStore, { StatsStore, StatsStoreStartInfo } from '../header/stats-store'
import statsStore, { StatsStore } from '../header/stats-store'
import scroller, { Scroller } from './scroller'
import TestModel, { UpdatableTestProps, UpdateTestCallback, TestProps } from '../test/test-model'
import { SessionProps } from '../sessions/sessions-model'
import type { ReporterStartInfo, ReporterRunState } from '@packages/types'
const localBus = new EventEmitter()
@@ -33,16 +34,7 @@ export interface Events {
__off: (() => void)
}
interface StartInfo extends StatsStoreStartInfo {
autoScrollingEnabled: boolean
scrollTop: number
studioActive: boolean
}
type CollectRunStateCallback = (arg: {
autoScrollingEnabled: boolean
scrollTop: number
}) => void
type CollectRunStateCallback = (arg: ReporterRunState) => void
const events: Events = {
appState,
@@ -72,10 +64,6 @@ const events: Events = {
runnablesStore.updateLog(log)
}))
runner.on('session:add', action('session:add', (props: SessionProps) => {
runnablesStore._withTest(props.testId, (test) => test.addSession(props))
}))
runner.on('reporter:log:remove', action('log:remove', (log: LogProps) => {
runnablesStore.removeLog(log)
}))
@@ -93,7 +81,7 @@ const events: Events = {
}
}))
runner.on('reporter:start', action('start', (startInfo: StartInfo) => {
runner.on('reporter:start', action('start', (startInfo: ReporterStartInfo) => {
appState.temporarilySetAutoScrolling(startInfo.autoScrollingEnabled)
runnablesStore.setInitialScrollTop(startInfo.scrollTop)
appState.setStudioActive(startInfo.studioActive)
@@ -193,8 +181,8 @@ const events: Events = {
runner.emit('get:user:editor', cb)
})
localBus.on('clear:session', (cb) => {
runner.emit('clear:session', cb)
localBus.on('clear:all:sessions', (cb) => {
runner.emit('clear:all:sessions', cb)
})
localBus.on('set:user:editor', (editor) => {

View File

@@ -6,7 +6,7 @@
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: inherit;
line-height: initial;
height: 18px;
max-width: 200px;
overflow: hidden;
@@ -62,6 +62,7 @@
}
}
.session-item:hover > .session-tag > .reporter-tag.successful-status,
.command-wrapper:hover > .command-pin-target > .command-wrapper-text > .command-controls > .reporter-tag.successful-status {
border-color: $gray-700;
}

View File

@@ -9,7 +9,6 @@ import RouteModel, { RouteProps } from '../routes/route-model'
import TestModel, { TestProps, UpdatableTestProps, UpdateTestCallback } from '../test/test-model'
import RunnableModel from './runnable-model'
import SuiteModel, { SuiteProps } from './suite-model'
import { SessionProps } from '../sessions/sessions-model'
const defaults = {
hasSingleTest: false,
@@ -25,7 +24,7 @@ interface Props {
scroller: Scroller
}
export type LogProps = AgentProps | CommandProps | RouteProps | SessionProps
export type LogProps = AgentProps | CommandProps | RouteProps
export type RunnableArray = Array<TestModel | SuiteModel>
@@ -44,11 +43,11 @@ export class RunnablesStore {
@observable isReady = defaults.isReady
@observable runnables: RunnableArray = []
/**
* Stores a list of all the runables files where the reporter
* Stores a list of all the runnables files where the reporter
* has passed without any specific order.
*
* key: spec FilePath
* content: RunableArray
* content: RunnableArray
*/
@observable runnablesHistory: Record<string, RunnableArray> = {}
@@ -62,7 +61,6 @@ export class RunnablesStore {
[key: string]: any
_tests: Record<string, TestModel> = {}
_logs: Record<string, Log> = {}
_runnablesQueue: Array<RunnableModel> = []
attemptingShowSnapshot = defaults.attemptingShowSnapshot
@@ -162,9 +160,9 @@ export class RunnablesStore {
return this._tests[id]
}
addLog (log: LogProps) {
this._withTest(log.testId, (test) => {
test.addLog(log)
addLog (props: LogProps) {
this._withTest(props.testId, (test) => {
test.addLog(props)
})
}

View File

@@ -7,19 +7,29 @@ export interface SessionProps extends InstrumentProps {
testCurrentRetry: number
sessionInfo: {
id: string
data: Record<string, {cookies: number, localStorage: number}>
isGlobalSession: boolean
status: 'creating' | 'created' | 'restored' |'restored' | 'recreating' | 'recreated' | 'failed'
}
}
export default class Session extends Instrument {
@observable name: string
@observable data: SessionProps['sessionInfo']['data']
@observable status: string
@observable isGlobalSession: boolean = false
constructor (props: SessionProps) {
super(props)
const { id, data } = props.sessionInfo
const { sessionInfo: { isGlobalSession, id, status } } = props
this.isGlobalSession = isGlobalSession
this.name = id
this.data = { ...data }
this.status = status
}
update (props: Partial<SessionProps>) {
const { sessionInfo, state } = props
this.status = sessionInfo?.status || ''
this.state = state || ''
}
}

View File

@@ -0,0 +1,146 @@
import React from 'react'
import Sessions from './sessions'
import SessionsModel from './sessions-model'
import events from '../lib/events'
describe('sessions instrument panel', { viewportWidth: 400 }, () => {
it('renders null when no sessions have been added', () => {
cy.mount(<Sessions model={[]}/>)
cy.get('.sessions-container').should('not.exist')
cy.percySnapshot()
})
describe('renders with sessions', () => {
const specSession = new SessionsModel({
name: 'session',
state: 'passed',
type: 'parent',
testId: '1',
id: 1,
testCurrentRetry: 1,
sessionInfo: {
id: 'spec_session',
isGlobalSession: false,
status: 'created',
},
})
const globalSession = new SessionsModel({
name: 'session',
state: 'passed',
type: 'parent',
testId: '1',
id: 2,
testCurrentRetry: 1,
sessionInfo: {
id: 'global_session',
isGlobalSession: true,
status: 'restored',
},
})
const failedSpecSession = new SessionsModel({
name: 'session',
state: 'failed',
type: 'parent',
testId: '1',
id: 3,
sessionInfo: {
id: 'spec_session_failed',
isGlobalSession: false,
status: 'failed',
},
testCurrentRetry: 1,
})
beforeEach(() => {
cy.mount(<Sessions model={[specSession, globalSession, failedSpecSession]}/>)
cy.get('.sessions-container').should('exist')
cy.get('.hook-header > .collapsible-header').as('header')
cy.contains('Clear All Sessions')
})
it('is collapsed by default', () => {
cy.get('@header').should('have.attr', 'aria-expanded', 'false')
cy.percySnapshot()
})
it('opens/closes when clicking panel', () => {
cy.get('@header').click()
cy.get('@header').should('have.attr', 'aria-expanded', 'true')
cy.get('.session-item')
.should('have.length', 3)
.should('be.visible')
cy.percySnapshot()
})
it('has spec session item', () => {
cy.get('@header').click()
cy.get('.session-item').eq(0)
.within(() => {
cy.contains('spec_session').should('have.class', 'spec-session')
cy.get('.global-session-icon').should('not.exist')
cy.get('.session-status').should('have.class', 'successful-status')
})
cy.percySnapshot()
})
it('has global session item', () => {
cy.get('@header').click()
cy.get('.session-item').eq(1)
.within(() => {
cy.contains('global_session').should('not.have.class', 'spec-session')
cy.get('.global-session-icon').should('exist')
cy.get('.session-status').should('have.class', 'successful-status')
})
cy.percySnapshot()
})
it('has failed session item', () => {
cy.get('@header').click()
cy.get('.session-item')
.eq(2)
.within(() => {
cy.contains('spec_session_failed').should('have.class', 'spec-session')
cy.get('.global-session-icon').should('not.exist')
cy.get('.session-status').should('have.class', 'failed-status')
})
cy.percySnapshot()
})
it('clicking session item prints details to the console', () => {
cy.spy(events, 'emit')
cy.get('@header').click()
cy.get('.session-item').eq(2).click()
cy.get('.cy-tooltip')
.should('have.text', 'Printed output to your console')
.then(() => {
expect(events.emit).to.be.calledWith('show:command', failedSpecSession.testId, failedSpecSession.id)
})
})
it('clicking "Clear All Sessions" button emits "clear:all:sessions" event', () => {
cy.spy(events, 'emit')
cy.contains('Clear All Sessions').click()
.then(() => {
expect(events.emit).to.be.calledWith('clear:all:sessions')
})
})
})
})

View File

@@ -1,6 +1,10 @@
.sessions-container.instruments-container {
.instrument-content {
padding: 0;
.sessions-container {
.session-content {
font-family: $monospace;
font-size: 12px;
font-style: normal;
font-weight: 500;
padding: 0 !important;
}
.clear-sessions {
@@ -8,24 +12,55 @@
color: $gray-400;
display: flex;
align-items: center;
padding: 4px 10px;
padding: 4px;
cursor: pointer;
&:hover,
&:focus {
color: $gray-50;
}
}
.session-item {
padding: 3px 10px;
background-color: $gray-1000;
color: $gray-400;
min-height: 20px;
.session-content .session-item-wrapper {
display: block;
margin-top: -1px;
padding: 0 2px 0 12px;
&:hover {
cursor: pointer;
background-color: $gray-900;
}
}
}
.session-content .session-item-wrapper:last-child {
.session-item {
border-bottom: none;
}
}
.session-item {
align-items: center;
border-bottom: 1px solid rgba($gray-900, 0.4);
display: flex;
color: $gray-400;
flex-flow: row nowrap;
justify-content: space-between;
min-height: 29px;
}
.session-info {
padding-left: 8px;
&.spec-session {
padding-left: 24px;
}
}
.session-tag {
margin-left: 4px;
}
.global-session-icon {
margin-right: 4px;
vertical-align: middle;
}
}

View File

@@ -1,76 +1,76 @@
import _ from 'lodash'
import cs from 'classnames'
import React from 'react'
import { observer } from 'mobx-react'
import GlobeIcon from '-!react-svg-loader!@packages/frontend-shared/src/assets/icons/globe_x12.svg'
import events, { Events } from '../lib/events'
import SessionsModel from './sessions-model'
import events from '../lib/events'
import Collapsible from '../collapsible/collapsible'
import Tag from '../lib/tag'
import FlashOnClick from '../lib/flash-on-click'
export interface SessionsProps {
export interface SessionPanelProps {
model: Record<string, SessionsModel>
events: Events
}
@observer
class Sessions extends React.Component<SessionsProps> {
static defaultProps = {
events,
const SessionRow = ({ name, isGlobalSession, id, state, status, testId }: SessionsModel) => {
const printToConsole = (id) => {
events.emit('show:command', testId, id)
}
printToConsole = (name) => {
const { id, testId } = this.props.model[name]
this.props.events.emit('show:command', testId, id)
}
render () {
const model = this.props.model
if (!_.size(model)) {
return null
}
return (
<div
className={cs('runnable-agents-region', {
'no-agents': !_.size(model),
})}
>
<div className='instruments-container sessions-container'>
<ul className='hooks-container'>
<li className='hook-item'>
<Collapsible
header={<>
Sessions <i style={{ textTransform: 'none' }}>({_.size(model)})</i>
</>
}
headerClass='hook-header'
headerExtras={
<div className="clear-sessions"
onClick={() => events.emit('clear:session')}
><span><i className="fas fa-ban" /> Clear All Sessions</span></div>}
contentClass='instrument-content'
>
<div>
{_.map(model, (sess) => {
return (<FlashOnClick
key={sess.name}
message='Printed output to your console'
onClick={() => this.printToConsole(sess.name)}
shouldShowMessage={() => true}
><div className="session-item" >{sess.name}</div></FlashOnClick>)
})}
</div>
</Collapsible>
</li>
</ul>
</div>
return (
<FlashOnClick
key={name}
message='Printed output to your console'
onClick={() => printToConsole(id)}
shouldShowMessage={() => true}
wrapperClassName='session-item-wrapper'
>
<div className='session-item'>
<span className={cs('session-info', { 'spec-session': !isGlobalSession })}>
{isGlobalSession && <GlobeIcon className='global-session-icon' />}
{name}
</span>
<span className='session-tag'>
<Tag
customClassName='session-status'
content={status}
type={`${state === 'failed' ? 'failed' : 'successful'}-status`}
/>
</span>
</div>
)
}
</FlashOnClick>
)
}
export default Sessions
const Sessions = ({ model }: SessionPanelProps) => {
const sessions = Object.values(model)
if (sessions.length === 0) {
return null
}
return (
<ul className='instruments-container hooks-container sessions-container'>
<li className='hook-item'>
<Collapsible
header={<>Sessions <i style={{ textTransform: 'none' }}>({sessions.length})</i></>}
headerClass='hook-header'
headerExtras={
<div
className="clear-sessions"
onClick={() => events.emit('clear:all:sessions')}
>
<span><i className="fas fa-ban" /> Clear All Sessions</span>
</div>
}
contentClass='instrument-content session-content'
>
{sessions.map((session) => (<SessionRow key={session.id} {...session} />))}
</Collapsible>
</li>
</ul>
)
}
export default observer(Sessions)

View File

@@ -10,7 +10,6 @@ import { CommandProps } from '../commands/command-model'
import { AgentProps } from '../agents/agent-model'
import { RouteProps } from '../routes/route-model'
import { RunnablesStore, LogProps } from '../runnables/runnables-store'
import { SessionProps } from '../sessions/sessions-model'
export type UpdateTestCallback = () => void
@@ -128,10 +127,6 @@ export default class Test extends Runnable {
})
}
addSession (props: SessionProps) {
return this._withAttempt(props.testCurrentRetry, (attempt) => attempt._addSession(props))
}
updateLog (props: LogProps) {
this._withAttempt(props.testCurrentRetry || this.currentRetry, (attempt: Attempt) => {
attempt.updateLog(props)

View File

@@ -1,5 +0,0 @@
// Studio tests have been removed with v10 update.
// You can find the tests in the PR below.
// @see https://github.com/cypress-io/cypress/pull/9542
export * from './studio-recorder'

View File

@@ -45,6 +45,7 @@ module.exports = {
PROJECTS: [],
PROJECT_PREFERENCES: {},
PROJECTS_CONFIG: {},
COHORTS: {},
}
},
@@ -184,6 +185,21 @@ module.exports = {
return fileUtil.set({ PROJECT_PREFERENCES: updatedPreferences })
},
getCohorts () {
return fileUtil.get('COHORTS', {})
},
insertCohort (cohort) {
return fileUtil.transaction((tx) => {
return tx.get('COHORTS', {}).then((cohorts) => {
return tx.set('COHORTS', {
...cohorts,
[cohort.name]: cohort,
})
})
})
},
remove () {
return fileUtil.remove()
},

View File

@@ -0,0 +1,25 @@
const cache = require('./cache')
import type { Cohort } from '@packages/types'
const debug = require('debug')('cypress:server:cohorts')
export = {
get: (): Promise<Record<string, Cohort>> => {
debug('Get cohorts')
return cache.getCohorts()
},
getByName: (name: string): Promise<Cohort> => {
debug('Get cohort name:', name)
return cache.getCohorts().then((cohorts) => {
debug('Get cohort returning:', cohorts[name])
return cohorts[name]
})
},
set: (cohort: Cohort) => {
debug('Set cohort', cohort)
return cache.insertCohort(cohort)
},
}

View File

@@ -2,10 +2,6 @@ import _ from 'lodash'
import type { ResolvedFromConfig } from '@packages/types'
import * as configUtils from '@packages/config'
export const setupFullConfigWithDefaults = configUtils.setupFullConfigWithDefaults
export const updateWithPluginValues = configUtils.updateWithPluginValues
export const setUrls = configUtils.setUrls
export function getResolvedRuntimeConfig (config, runtimeConfig) {

View File

@@ -140,7 +140,7 @@ export const getExperiments = (project: CypressProject, names = experimental.nam
}
/**
* Whilelist known experiments here to avoid accidentally showing
* Allow known experiments here to avoid accidentally showing
* any config key that starts with "experimental" prefix
*/
// @ts-ignore

View File

@@ -17,6 +17,7 @@ import type {
import browserUtils from './browsers/utils'
import auth from './gui/auth'
import user from './user'
import cohorts from './cohorts'
import { openProject } from './open_project'
import cache from './cache'
import { graphqlSchema } from '@packages/graphql/src/schema'
@@ -195,6 +196,17 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
return availableEditors
},
},
cohortsApi: {
async getCohorts () {
return cohorts.get()
},
async getCohort (name: string) {
return cohorts.getByName(name)
},
async insertCohort (cohort) {
cohorts.set(cohort)
},
},
})
return ctx

View File

@@ -718,7 +718,7 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea
async function runEachSpec (spec: SpecWithRelativeRoot, index: number, length: number, estimated: number) {
if (!options.quiet) {
printResults.displaySpecHeader(spec.baseName, index + 1, length, estimated)
printResults.displaySpecHeader(spec.relativeToCommonRoot, index + 1, length, estimated)
}
const { results } = await runSpec(config, spec, options, estimated, isFirstSpec, index === length - 1)

View File

@@ -1,33 +1,47 @@
import type { CyCookie } from './browsers/cdp_automation'
import type { ServerSessionData, StoredSessions } from '@packages/types'
interface SessionData {
cookies: CyCookie[]
id: string
localStorage: object
sessionStorage: object
}
const state = {
sessions: {},
type State = {
globalSessions: StoredSessions
specSessions: StoredSessions
}
export function saveSession (data: SessionData) {
const state: State = {
globalSessions: {},
specSessions: {},
}
export function saveSession (data: ServerSessionData): void {
if (!data.id) throw new Error('session data had no id')
state.sessions[data.id] = data
if (data.cacheAcrossSpecs) {
state.globalSessions[data.id] = data
return
}
state.specSessions[data.id] = data
}
export function getSession (id: string): SessionData {
const session = state.sessions[id]
export function getActiveSessions (): StoredSessions {
return state.globalSessions
}
export function getSession (id: string): ServerSessionData {
const session = state.globalSessions[id] || state.specSessions[id]
if (!session) throw new Error(`session with id "${id}" not found`)
return session
}
export function getState () {
export function getState (): State {
return state
}
export function clearSessions () {
state.sessions = {}
export function clearSessions (clearAllSessions: boolean = false): void {
state.specSessions = {}
if (clearAllSessions) {
state.globalSessions = {}
}
}

View File

@@ -26,22 +26,13 @@ import runEvents from './plugins/run_events'
// eslint-disable-next-line no-duplicate-imports
import type { Socket } from '@packages/socket'
import type { RunState, CachedTestState } from '@packages/types'
type StartListeningCallbacks = {
onSocketConnection: (socket: any) => void
}
type RunnerEvent =
'reporter:restart:test:run'
| 'runnables:ready'
| 'run:start'
| 'test:before:run:async'
| 'reporter:log:add'
| 'reporter:log:state:changed'
| 'paused'
| 'test:after:hooks'
| 'run:end'
const runnerEvents: RunnerEvent[] = [
const runnerEvents = [
'reporter:restart:test:run',
'runnables:ready',
'run:start',
@@ -51,18 +42,9 @@ const runnerEvents: RunnerEvent[] = [
'paused',
'test:after:hooks',
'run:end',
]
] as const
type ReporterEvent =
'runner:restart'
| 'runner:abort'
| 'runner:console:log'
| 'runner:console:error'
| 'runner:show:snapshot'
| 'runner:hide:snapshot'
| 'reporter:restarted'
const reporterEvents: ReporterEvent[] = [
const reporterEvents = [
// "go:to:file"
'runner:restart',
'runner:abort',
@@ -71,7 +53,7 @@ const reporterEvents: ReporterEvent[] = [
'runner:show:snapshot',
'runner:hide:snapshot',
'reporter:restarted',
]
] as const
const debug = Debug('cypress:server:socket-base')
@@ -156,7 +138,7 @@ export class SocketBase {
options,
callbacks: StartListeningCallbacks,
) {
let existingState = null
let runState: RunState | undefined = undefined
_.defaults(options, {
socketId: null,
@@ -350,7 +332,6 @@ export class SocketBase {
})
// TODO: what to do about runner disconnections?
socket.on('spec:changed', (spec) => {
return options.onSpecChanged(spec)
})
@@ -402,8 +383,7 @@ export class SocketBase {
})
}
// retry for up to data.timeout
// or 1 second
// retry for up to data.timeout or 1 second
return Bluebird
.try(tryConnected)
.timeout(data.timeout != null ? data.timeout : 1000)
@@ -428,7 +408,7 @@ export class SocketBase {
switch (eventName) {
case 'preserve:run:state':
existingState = args[0]
runState = args[0]
return null
case 'resolve:url': {
@@ -467,21 +447,20 @@ export class SocketBase {
return task.run(cfgFile ?? null, args[0])
case 'save:session':
return session.saveSession(args[0])
case 'clear:session':
return session.clearSessions()
case 'clear:sessions':
return session.clearSessions(args[0])
case 'get:session':
return session.getSession(args[0])
case 'reset:session:state':
case 'reset:cached:test:state':
runState = undefined
cookieJar.removeAllCookies()
session.clearSessions()
resetRenderedHTMLOrigins()
return
return resetRenderedHTMLOrigins()
case 'get:rendered:html:origins':
return options.getRenderedHTMLOrigins()
case 'reset:rendered:html:origins': {
case 'reset:rendered:html:origins':
return resetRenderedHTMLOrigins()
}
case 'cross:origin:automation:cookies:received':
return this.localBus.emit('cross:origin:automation:cookies:received')
case 'request:sent:with:credentials':
@@ -500,16 +479,18 @@ export class SocketBase {
})
})
socket.on('get:existing:run:state', (cb) => {
const s = existingState
socket.on('get:cached:test:state', (cb: (runState: RunState | null, testState: CachedTestState) => void) => {
const s = runState
if (s) {
existingState = null
return cb(s)
const cachedTestState: CachedTestState = {
activeSessions: session.getActiveSessions(),
}
return cb()
if (s) {
runState = undefined
}
return cb(s || {}, cachedTestState)
})
socket.on('save:app:state', (state, cb) => {
@@ -550,7 +531,7 @@ export class SocketBase {
// todo(lachlan): post 10.0 we should not pass the
// editor (in the `fileDetails.where` key) from the
// front-end, but rather rely on the server context
// to grab the prefered editor, like I'm doing here,
// to grab the preferred editor, like I'm doing here,
// so we do not need to
// maintain two sources of truth for the preferred editor
// adding this conditional to maintain backwards compat with

View File

@@ -113,7 +113,7 @@ function macOSRemovePrivate (str: string) {
function collectTestResults (obj: { video?: boolean, screenshots?: Screenshot[], spec?: any, stats?: any }, estimated: number) {
return {
name: _.get(obj, 'spec.name'),
baseName: _.get(obj, 'spec.baseName'),
relativeToCommonRoot: _.get(obj, 'spec.relativeToCommonRoot'),
tests: _.get(obj, 'stats.tests'),
passes: _.get(obj, 'stats.passes'),
pending: _.get(obj, 'stats.pending'),
@@ -203,7 +203,7 @@ export function displayRunStarting (options: { browser: Browser, config: Cfg, gr
const formatSpecs = (specs) => {
// 25 found: (foo.spec.js, bar.spec.js, baz.spec.js)
const names = _.map(specs, 'baseName')
const names = _.map(specs, 'relativeToCommonRoot')
const specsTruncated = _.truncate(names.join(', '), { length: 250 })
const stringifiedSpecs = [
@@ -325,7 +325,7 @@ export function renderSummaryTable (runUrl: string | undefined, results: any) {
const ms = duration.format(stats.wallClockDuration || 0)
const formattedSpec = formatPath(spec.baseName, getWidth(table2, 1))
const formattedSpec = formatPath(spec.relativeToCommonRoot, getWidth(table2, 1))
if (run.skippedSpec) {
return table2.push([
@@ -398,7 +398,7 @@ export function displayResults (obj: { screenshots?: Screenshot[] }, estimated:
['Video:', results.video],
['Duration:', results.duration],
estimated ? ['Estimated:', results.estimated] : undefined,
['Spec Ran:', formatPath(results.baseName, getWidth(table, 1), c)],
['Spec Ran:', formatPath(results.relativeToCommonRoot, getWidth(table, 1), c)],
])
.compact()
.map((arr) => {

View File

@@ -17,6 +17,7 @@ const { SocketE2E } = require(`../../lib/socket-e2e`)
const httpsServer = require(`@packages/https-proxy/test/helpers/https_server`)
const SseStream = require('ssestream')
const EventSource = require('eventsource')
const { setupFullConfigWithDefaults } = require('@packages/config')
const config = require(`../../lib/config`)
const { ServerE2E } = require(`../../lib/server-e2e`)
const pluginsModule = require(`../../lib/plugins`)
@@ -107,7 +108,7 @@ describe('Routes', () => {
// get all the config defaults
// and allow us to override them
// for each test
return config.setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
.then((cfg) => {
// use a jar for each test
// but reset it automatically

View File

@@ -5,6 +5,7 @@ const http = require('http')
const rp = require('@cypress/request-promise')
const Promise = require('bluebird')
const evilDns = require('evil-dns')
const { setupFullConfigWithDefaults } = require('@packages/config')
const httpsServer = require(`@packages/https-proxy/test/helpers/https_server`)
const config = require(`../../lib/config`)
const { ServerE2E } = require(`../../lib/server-e2e`)
@@ -47,7 +48,7 @@ describe('Server', () => {
// get all the config defaults
// and allow us to override them
// for each test
return config.setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
.then((cfg) => {
// use a jar for each test
// but reset it automatically
@@ -102,8 +103,6 @@ describe('Server', () => {
this.srv = this.server.getHttpServer()
// @session = new (Session({app: @srv}))
this.proxy = `http://localhost:${port}`
this.buffers = this.server._networkProxy.http.buffers

View File

@@ -21,7 +21,7 @@ const { createRoutes } = require(`../../lib/routes`)
process.env.CYPRESS_INTERNAL_ENV = 'development'
const CA = require('@packages/https-proxy').CA
const Config = require('../../lib/config')
const { setupFullConfigWithDefaults } = require('@packages/config')
const { ServerE2E } = require('../../lib/server-e2e')
const { SocketE2E } = require('../../lib/socket-e2e')
const { _getArgs } = require('../../lib/browsers/chrome')
@@ -350,7 +350,7 @@ describe('Proxy Performance', function () {
https: { cert, key },
}).start(HTTPS_PROXY_PORT),
Config.setupFullConfigWithDefaults({
setupFullConfigWithDefaults({
projectRoot: '/tmp/a',
config: {
supportFile: false,

View File

@@ -256,6 +256,28 @@ describe('lib/cache', () => {
PROJECTS: ['foo'],
PROJECT_PREFERENCES: {},
PROJECTS_CONFIG: {},
COHORTS: {},
})
})
})
})
context('cohorts', () => {
it('should get no cohorts when empty', () => {
return cache.getCohorts().then((cohorts) => {
expect(cohorts).to.deep.eq({})
})
})
it('should insert a cohort', () => {
const cohort = {
name: 'cohort_id',
cohort: 'A',
}
return cache.insertCohort(cohort).then(() => {
return cache.getCohorts().then((cohorts) => {
expect(cohorts).to.deep.eq({ [cohort.name]: cohort })
})
})
})

View File

@@ -0,0 +1,63 @@
require('../spec_helper')
import { Cohort } from '@packages/types'
import cache from '../../lib/cache'
import cohorts from '../../lib/cohorts'
describe('lib/cohort', () => {
context('.get', () => {
it('calls cache.get', async () => {
const cohortTest: Cohort = {
name: 'testName',
cohort: 'A',
}
const cohortTest2: Cohort = {
name: 'testName2',
cohort: 'B',
}
const allCohorts = {
[cohortTest.name]: cohortTest,
[cohortTest2.name]: cohortTest2,
}
sinon.stub(cache, 'getCohorts').resolves(allCohorts)
return cohorts.get().then((cohorts) => {
expect(cohorts).to.eq(allCohorts)
})
})
})
context('.getByName', () => {
it('calls cache.getByName', async () => {
const cohortTest: Cohort = {
name: 'testName',
cohort: 'A',
}
sinon.stub(cache, 'getCohorts').resolves({
[cohortTest.name]: cohortTest,
})
return cohorts.getByName(cohortTest.name).then((cohort) => {
expect(cohort).to.eq(cohortTest)
})
})
})
context('.set', () => {
it('calls cache.set', async () => {
const cohortTest: Cohort = {
name: 'testName',
cohort: 'A',
}
return cohorts.set(cohortTest).then(() => {
return cohorts.getByName(cohortTest.name).then((cohort) => {
expect(cohort).to.eq(cohortTest)
})
})
})
})
})

View File

@@ -3,6 +3,7 @@ require('../spec_helper')
const path = require('path')
const chokidar = require('chokidar')
const pkg = require('@packages/root')
const { setupFullConfigWithDefaults } = require('@packages/config')
const Fixtures = require('@tooling/system-tests')
const { sinon } = require('../spec_helper')
const user = require(`../../lib/user`)
@@ -44,7 +45,7 @@ describe.skip('lib/project-base', () => {
.then((obj = {}) => {
({ projectId: this.projectId } = obj)
return config.setupFullConfigWithDefaults({ projectName: 'project', projectRoot: '/foo/bar' }, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults({ projectName: 'project', projectRoot: '/foo/bar' }, getCtx().file.getFilesByGlob)
.then((config1) => {
this.config = config1
this.project = new ProjectBase({ projectRoot: this.todosPath, testingType: 'e2e' })

View File

@@ -5,7 +5,7 @@ const os = require('os')
const express = require('express')
const Promise = require('bluebird')
const { connect } = require('@packages/network')
const config = require(`../../lib/config`)
const { setupFullConfigWithDefaults } = require('@packages/config')
const { ServerE2E } = require(`../../lib/server-e2e`)
const { SocketE2E } = require(`../../lib/socket-e2e`)
const fileServer = require(`../../lib/file_server`)
@@ -22,7 +22,7 @@ describe('lib/server', () => {
beforeEach(function () {
this.server = new ServerE2E()
return config.setupFullConfigWithDefaults({ projectRoot: '/foo/bar/', config: { supportFile: false } }, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults({ projectRoot: '/foo/bar/', config: { supportFile: false } }, getCtx().file.getFilesByGlob)
.then((cfg) => {
this.config = cfg
})
@@ -38,7 +38,7 @@ describe('lib/server', () => {
})
// TODO: Figure out correct configuration to run these tests and/or which ones we need to keep.
// The introducion of server-base/socket-base and the `ensureProp` function made unit testing
// The introduction of server-base/socket-base and the `ensureProp` function made unit testing
// the server difficult.
describe.skip('lib/server', () => {
beforeEach(function () {
@@ -51,7 +51,7 @@ describe.skip('lib/server', () => {
sinon.stub(fileServer, 'create').returns(this.fileServer)
return config.setupFullConfigWithDefaults({ projectRoot: '/foo/bar/' }, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults({ projectRoot: '/foo/bar/' }, getCtx().file.getFilesByGlob)
.then((cfg) => {
this.config = cfg
this.server = new ServerE2E()

View File

@@ -3,21 +3,22 @@ require('../spec_helper')
const _ = require('lodash')
const path = require('path')
const Promise = require('bluebird')
const socketIo = require('@packages/socket/lib/browser')
const httpsAgent = require('https-proxy-agent')
const errors = require(`../../lib/errors`)
const { SocketE2E } = require(`../../lib/socket-e2e`)
const { ServerE2E } = require(`../../lib/server-e2e`)
const { Automation } = require(`../../lib/automation`)
const exec = require(`../../lib/exec`)
const preprocessor = require(`../../lib/plugins/preprocessor`)
const { fs } = require(`../../lib/util/fs`)
const socketIo = require('@packages/socket/lib/browser')
const Fixtures = require('@tooling/system-tests')
const firefoxUtil = require(`../../lib/browsers/firefox-util`).default
const { createRoutes } = require(`../../lib/routes`)
const { getCtx } = require(`../../lib/makeDataContext`)
const errors = require('../../lib/errors')
const { SocketE2E } = require('../../lib/socket-e2e')
const { ServerE2E } = require('../../lib/server-e2e')
const { Automation } = require('../../lib/automation')
const exec = require('../../lib/exec')
const preprocessor = require('../../lib/plugins/preprocessor')
const { fs } = require('../../lib/util/fs')
const session = require('../../lib/session')
const firefoxUtil = require('../../lib/browsers/firefox-util').default
const { createRoutes } = require('../../lib/routes')
const { getCtx } = require('../../lib/makeDataContext')
const { sinon } = require('../spec_helper')
let ctx
@@ -33,6 +34,7 @@ describe('lib/socket', () => {
sinon.stub(ctx.actions.project, 'initializeActiveProject')
Fixtures.scaffold()
session.clearSessions(true)
this.todosPath = Fixtures.projectPath('todos')
@@ -453,7 +455,7 @@ describe('lib/socket', () => {
})
})
context('on(get:fixture)', () => {
context('on(backend:request, get:fixture)', () => {
it('returns the fixture object', function (done) {
const cb = function (resp) {
expect(resp.response).to.deep.eq([
@@ -488,7 +490,7 @@ describe('lib/socket', () => {
})
})
context('on(http:request)', () => {
context('on(backend:request, http:request)', () => {
it('calls socket#onRequest', function (done) {
sinon.stub(this.options, 'onRequest').resolves({ foo: 'bar' })
@@ -512,7 +514,7 @@ describe('lib/socket', () => {
})
})
context('on(exec)', () => {
context('on(backend:request, exec)', () => {
it('calls exec#run with project root and options', function (done) {
const run = sinon.stub(exec, 'run').returns(Promise.resolve('Desktop Music Pictures'))
@@ -539,7 +541,7 @@ describe('lib/socket', () => {
})
})
context('on(firefox:force:gc)', () => {
context('on(backend:request, firefox:force:gc)', () => {
it('calls firefoxUtil#collectGarbage', function (done) {
sinon.stub(firefoxUtil, 'collectGarbage').resolves()
@@ -598,6 +600,180 @@ describe('lib/socket', () => {
})
})
})
context('on(backend:request, save:session)', () => {
it('saves spec sessions', function (done) {
const sessionData = {
id: 'spec',
cacheAcrossSpecs: false,
}
this.client.emit('backend:request', 'save:session', sessionData, () => {
const state = session.getState()
expect(state).to.deep.eq({
globalSessions: {},
specSessions: {
'spec': sessionData,
},
})
done()
})
})
it('saves global sessions', function (done) {
const sessionData = {
id: 'global',
cacheAcrossSpecs: true,
}
this.client.emit('backend:request', 'save:session', sessionData, () => {
const state = session.getState()
expect(state).to.deep.eq({
globalSessions: {
'global': sessionData,
},
specSessions: {},
})
done()
})
})
it('returns error if session data has no id', function (done) {
const sessionData = {}
this.client.emit('backend:request', 'save:session', sessionData, ({ error }) => {
expect(error.message).to.eq('session data had no id')
done()
})
})
})
context('on(backend:request, clear:sessions)', () => {
it('clears spec sessions', function (done) {
let state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
state.specSessions = {
spec: { id: 'spec' },
}
this.client.emit('backend:request', 'clear:sessions', false, () => {
expect(state).to.deep.eq({
globalSessions: {
'global': { id: 'global' },
},
specSessions: {},
})
done()
})
})
it('clears all sessions', function (done) {
let state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
state.specSessions = {
spec: { id: 'spec' },
}
this.client.emit('backend:request', 'clear:sessions', true, () => {
expect(state).to.deep.eq({
globalSessions: {},
specSessions: {},
})
done()
})
})
})
context('on(backend:request, get:session)', () => {
it('returns global session', function (done) {
const state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
this.client.emit('backend:request', 'get:session', 'global', ({ response, error }) => {
expect(error).to.be.undefined
expect(response).deep.eq({
id: 'global',
})
done()
})
})
it('returns spec session', function (done) {
const state = session.getState()
state.globalSessions = {}
state.specSessions = {
'spec': { id: 'spec' },
}
this.client.emit('backend:request', 'get:session', 'spec', ({ response, error }) => {
expect(error).to.be.undefined
expect(response).deep.eq({
id: 'spec',
})
done()
})
})
it('returns error when session does not exist', function (done) {
const state = session.getState()
state.globalSessions = {}
state.specSessions = {}
this.client.emit('backend:request', 'get:session', 1, ({ response, error }) => {
expect(response).to.be.undefined
expect(error.message).to.eq('session with id "1" not found')
done()
})
})
})
context('on(backend:request, reset:cached:test:state)', () => {
it('clears spec sessions', function (done) {
const state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
state.specSessions = {
local: { id: 'local' },
}
this.client.emit('backend:request', 'reset:cached:test:state', ({ error }) => {
expect(error).to.be.undefined
expect(state).to.deep.eq({
globalSessions: {
'global': { id: 'global' },
},
specSessions: {},
})
done()
})
})
})
})
context('unit', () => {

View File

@@ -2,6 +2,7 @@ export interface Cache {
PROJECTS: string[]
PROJECT_PREFERENCES: Record<string, Preferences>
USER: CachedUser
COHORTS: Record<string, Cohort>
}
import type { AllowedState } from './preferences'
@@ -13,3 +14,8 @@ export interface CachedUser {
name: string
email: string
}
export interface Cohort {
name: string
cohort: string
}

View File

@@ -1,19 +1,46 @@
export interface RunState {
import type { ReporterRunState, StudioRecorderState } from './reporter'
interface MochaRunnerState {
startTime?: number
currentId?: number
currentId?: number | null
emissions?: Emissions
tests?: unknown
tests?: Record<string, Cypress.ObjectLike>
passed?: number
failed?: number
pending?: number
numLogs?: number
}
export type RunState = MochaRunnerState & ReporterRunState & {
studio?: StudioRecorderState
isSpecsListOpen?: boolean
}
export interface Emissions {
started: Record<string, boolean>
ended: Record<string, boolean>
}
interface HtmlWebStorage {
origin: string
value: Record<string, any>
}
export interface ServerSessionData {
id: string
cacheAcrossSpecs: boolean
cookies: Cypress.Cookie[] | null
localStorage: Array<HtmlWebStorage> | null
sessionStorage: Array<HtmlWebStorage> | null
setup: string
}
export type StoredSessions = Record<string, ServerSessionData>
export interface CachedTestState {
activeSessions: StoredSessions
}
export type Instrument = 'agent' | 'command' | 'route'
export type TestState = 'active' | 'failed' | 'pending' | 'passed' | 'processing'

View File

@@ -28,6 +28,8 @@ export {
RESOLVED_FROM,
} from './config'
export * from './reporter'
export * from './server'
export * from './util'

View File

@@ -25,10 +25,7 @@ export const allowedKeys: Readonly<Array<keyof AllowedState>> = [
'firstOpenedCypress',
'showedStudioModal',
'preferredOpener',
'ctReporterWidth',
'ctIsSpecsListOpen',
'isSpecsListOpen',
'ctSpecListWidth',
'firstOpened',
'lastOpened',
'lastProjectId',
@@ -61,9 +58,6 @@ export type AllowedState = Partial<{
firstOpenedCypress: Maybe<number>
showedStudioModal: Maybe<boolean>
preferredOpener: Editor | undefined
ctReporterWidth: Maybe<number>
ctIsSpecsListOpen: Maybe<boolean>
ctSpecListWidth: Maybe<number>
lastProjectId: Maybe<string>
firstOpened: Maybe<number>
lastOpened: Maybe<number>

View File

@@ -0,0 +1,24 @@
export interface StudioRecorderState {
suiteId?: string
testId?: string
url?: string
}
export interface ReporterRunState {
autoScrollingEnabled?: boolean
scrollTop?: number
}
export interface StatsStoreStartInfo {
startTime: string
numPassed?: number
numFailed?: number
numPending?: number
}
export interface ReporterStartInfo extends StatsStoreStartInfo {
isSpecsListOpen: boolean
autoScrollingEnabled: boolean
scrollTop: number
studioActive: boolean
}

View File

@@ -275,15 +275,15 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 1.2.3 │
│ Browser: FooBrowser 88 │
│ Specs: 3 found (spec.cy.js, stdout_specfile.cy.js, stdout_specfile_display_spec_with_a_re │
│ ally_long_name_that_never_has_a_line_break_or_new_line.cy.js)
│ Searched: cypress/e2e/nested-1/nested-2/nested-3/*
│ Specs: 4 found (spec.cy.js, stdout_specfile.cy.js, stdout_specfile_display_spec_with_a_re │
│ ally_long_name_that_never_has_a_line_break_or_new_line.cy.js, nested-4/spec.cy.js)
│ Searched: cypress/e2e/nested-1/nested-2/nested-3/**/*
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: spec.cy.js (1 of 3)
Running: spec.cy.js (1 of 4)
stdout_specfile_display_spec
@@ -316,7 +316,7 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: stdout_specfile.cy.js (2 of 3)
Running: stdout_specfile.cy.js (2 of 4)
stdout_specfile_display_spec
@@ -349,7 +349,7 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: stdout_specfile_display_spec_with_a_really_long_name_that_never_has_ (3 of 3)
Running: stdout_specfile_display_spec_with_a_really_long_name_that_never_has_ (3 of 4)
a_line_break_or_new_line.cy.js
@@ -391,6 +391,39 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
ne.cy.js.mp4
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: nested-4/spec.cy.js (4 of 4)
stdout_specfile_display_spec
✓ passes
1 passing
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 1 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: true │
│ Duration: X seconds │
│ Spec Ran: nested-4/spec.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
(Video)
- Started processing: Compressing to 32 CRF
- Finished processing: /XXX/XXX/XXX/cypress/videos/nested-4/spec.cy.js.mp4 (X second)
====================================================================================================
(Run Finished)
@@ -405,8 +438,10 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
│ ✔ stdout_specfile_display_spec_with_a XX:XX 1 1 - - - │
│ _really_long_name_that_never_has_a_ │
│ line_break_or_new_line.cy.js │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ nested-4/spec.cy.js XX:XX 1 1 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! XX:XX 3 3 - - -
✔ All specs passed! XX:XX 4 4 - - -
`

Some files were not shown because too many files have changed in this diff Show More