diff --git a/circle.yml b/circle.yml index bdee10044a15..0f2a14f96fa2 100644 --- a/circle.yml +++ b/circle.yml @@ -40,7 +40,7 @@ macWorkflowFilters: &mac-workflow-filters or: - equal: [ develop, << pipeline.git.branch >> ] - equal: [ feature-multidomain, << pipeline.git.branch >> ] - - equal: [ use-contexts, << pipeline.git.branch >> ] + - equal: [ new-cmd-group-9.x, << pipeline.git.branch >> ] - matches: pattern: "-release$" value: << pipeline.git.branch >> diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 028627380993..fd2af898a482 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -5764,6 +5764,7 @@ declare namespace Cypress { name: string /** Override *name* for display purposes only */ displayName: string + /** additional information to include in the log */ message: any /** Set to false if you want to control the finishing of the command in the log yourself */ autoEnd: boolean diff --git a/packages/driver/src/cy/commands/xhr.ts b/packages/driver/src/cy/commands/xhr.ts index a7e34226b338..3288da307cbe 100644 --- a/packages/driver/src/cy/commands/xhr.ts +++ b/packages/driver/src/cy/commands/xhr.ts @@ -201,7 +201,7 @@ const startXhrServer = (cy, state, config) => { Cypress.ProxyLogging.addXhrLog({ xhr, route, log, stack }) - return log.snapshot('request') + return log?.snapshot('request') }, onLoad: (xhr) => { diff --git a/packages/driver/src/cy/logGroup.ts b/packages/driver/src/cy/logGroup.ts new file mode 100644 index 000000000000..5444e352c4c1 --- /dev/null +++ b/packages/driver/src/cy/logGroup.ts @@ -0,0 +1,42 @@ +import { $Command } from '../cypress/command' +import $errUtils from '../cypress/error_utils' + +export default (Cypress, userOptions: Cypress.LogGroup.Config, fn: Cypress.LogGroup.ApiCallback) => { + const cy = Cypress.cy + + const shouldEmitLog = userOptions.log === undefined ? true : userOptions.log + + const options: Cypress.InternalLogConfig = { + ...userOptions, + instrument: 'command', + groupStart: true, + emitOnly: !shouldEmitLog, + } + + const log = Cypress.log(options) + + if (!_.isFunction(fn)) { + $errUtils.throwErrByPath('group.missing_fn', { onFail: log }) + } + + // An internal command is inserted to create a divider between + // commands inside group() callback and commands chained to it. + const restoreCmdIndex = cy.state('index') + 1 + + const endLogGroupCmd = $Command.create({ + name: 'end-logGroup', + injected: true, + }) + + const forwardYieldedSubject = () => { + if (log) { + log.endGroup() + } + + return endLogGroupCmd.get('prev').get('subject') + } + + cy.queue.insert(restoreCmdIndex, endLogGroupCmd.set('fn', forwardYieldedSubject)) + + return fn(log) +} diff --git a/packages/driver/src/cy/multi-domain/index.ts b/packages/driver/src/cy/multi-domain/index.ts index 71d4b3354fa5..95ab17a4a735 100644 --- a/packages/driver/src/cy/multi-domain/index.ts +++ b/packages/driver/src/cy/multi-domain/index.ts @@ -128,7 +128,7 @@ export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, const _reject = (err) => { cleanup() - log.error(err) + log?.error(err) reject(err) } diff --git a/packages/driver/src/cy/net-stubbing/route-matcher-log.ts b/packages/driver/src/cy/net-stubbing/route-matcher-log.ts index 7fb3d7d8a77f..990947d962db 100644 --- a/packages/driver/src/cy/net-stubbing/route-matcher-log.ts +++ b/packages/driver/src/cy/net-stubbing/route-matcher-log.ts @@ -22,8 +22,8 @@ export function getDisplayUrlMatcher (matcher: RouteMatcherOptions): string { return $utils.stringify(displayMatcher) } -export function getRouteMatcherLogConfig (matcher: RouteMatcherOptions, isStubbed: boolean, alias: string | void, staticResponse?: StaticResponse): Partial { - const obj: Partial = { +export function getRouteMatcherLogConfig (matcher: RouteMatcherOptions, isStubbed: boolean, alias: string | void, staticResponse?: StaticResponse): Partial { + const obj: Partial = { name: 'route', method: String(matcher.method || '*'), url: getDisplayUrlMatcher(matcher), diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index da33b9fa33e6..059dc820b70c 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -583,7 +583,9 @@ export default { docsUrl: 'https://on.cypress.io/go', }, }, - + group: { + missing_fn: '`group` API must be called with a function.', + }, hover: { not_implemented: { message: [ diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index 52e152277f70..5e82251c75e8 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -112,7 +112,7 @@ export const LogUtils = { }, } -const defaults = function (state, config, obj) { +const defaults = function (state: Cypress.State, config, obj) { const instrument = obj.instrument != null ? obj.instrument : 'command' // dont set any defaults if this @@ -129,7 +129,7 @@ const defaults = function (state, config, obj) { // but in cases where the command purposely does not log // then it could still be logged during a failure, which // is why we normalize its type value - if (!parentOrChildRe.test(obj.type)) { + if (typeof obj.type === 'string' && !parentOrChildRe.test(obj.type)) { // does this command have a previously linked command // by chainer id obj.type = (current != null ? current.hasPreviouslyLinkedCommand() : undefined) ? 'child' : 'parent' @@ -206,18 +206,18 @@ const defaults = function (state, config, obj) { }, }) - const logGroup = _.last(state('logGroup')) + const logGroupIds = state('logGroupIds') || [] - if (logGroup) { - obj.group = logGroup + if (logGroupIds.length) { + obj.group = _.last(logGroupIds) } if (obj.groupEnd) { - state('logGroup', _.slice(state('logGroup'), 0, -1)) + state('logGroupIds', _.slice(logGroupIds, 0, -1)) } if (obj.groupStart) { - state('logGroup', (state('logGroup') || []).concat(obj.id)) + state('logGroupIds', (logGroupIds).concat(obj.id)) } return obj @@ -225,7 +225,7 @@ const defaults = function (state, config, obj) { class Log { cy: any - state: any + state: Cypress.State config: any fireChangeEvent: ((log) => (void | undefined)) obj: any @@ -236,7 +236,8 @@ class Log { this.cy = cy this.state = state this.config = config - this.fireChangeEvent = fireChangeEvent + // only fire the log:state:changed event as fast as every 4ms + this.fireChangeEvent = _.debounce(fireChangeEvent, 4) this.obj = defaults(state, config, obj) extendEvents(this) @@ -376,6 +377,13 @@ class Log { } error (err) { + const logGroupIds = this.state('logGroupIds') || [] + + // current log was responsible to creating the current log group so end the current group + if (_.last(logGroupIds) === this.attributes.id) { + this.endGroup() + } + this.set({ ended: true, error: err, @@ -404,6 +412,10 @@ class Log { return this } + endGroup () { + this.state('logGroupIds', _.slice(this.state('logGroupIds'), 0, -1)) + } + getError (err) { return err.stack || err.message } @@ -561,16 +573,8 @@ class LogManager { this.logs[id] = true } - // only fire the log:state:changed event - // as fast as every 4ms fireChangeEvent (log) { - const triggerStateChanged = () => { - return this.trigger(log, 'command:log:changed') - } - - const debounceFn = _.debounce(triggerStateChanged, 4) - - return debounceFn() + return this.trigger(log, 'command:log:changed') } createLogFn (cy, state, config) { diff --git a/packages/driver/src/cypress/proxy-logging.ts b/packages/driver/src/cypress/proxy-logging.ts index 883f669ac4b8..2c24381de418 100644 --- a/packages/driver/src/cypress/proxy-logging.ts +++ b/packages/driver/src/cypress/proxy-logging.ts @@ -50,7 +50,7 @@ function getDisplayUrl (url: string) { return url } -function getDynamicRequestLogConfig (req: Omit): Partial { +function getDynamicRequestLogConfig (req: Omit): Partial { const last = _.last(req.interceptions) let alias = last ? last.interception.request.alias || last.route.alias : undefined @@ -64,7 +64,7 @@ function getDynamicRequestLogConfig (req: Omit): Partial): Partial { +function getRequestLogConfig (req: Omit): Partial { function getStatus (): string | undefined { const { stubbed, reqModified, resModified } = req.flags @@ -392,7 +392,7 @@ export default class ProxyLogging { const proxyRequest = new ProxyRequest(preRequest) const logConfig = getRequestLogConfig(proxyRequest as Omit) - proxyRequest.log = this.Cypress.log(logConfig).snapshot('request') + proxyRequest.log = this.Cypress.log(logConfig)?.snapshot('request') this.proxyRequests.push(proxyRequest as ProxyRequest) diff --git a/packages/driver/src/cypress/runner.ts b/packages/driver/src/cypress/runner.ts index a37479c10299..59566780bac6 100644 --- a/packages/driver/src/cypress/runner.ts +++ b/packages/driver/src/cypress/runner.ts @@ -1774,10 +1774,9 @@ export default { test = getTestById(testId) if (test) { - // pluralize the instrument - // as a property on the runnable - let name - const logs = test[name = `${instrument}s`] != null ? test[name] : (test[name] = []) + // pluralize the instrument as a property on the runnable + const name = `${instrument}s` + const logs = test[name] != null ? test[name] : (test[name] = []) // else push it onto the logs return logs.push(attrs) diff --git a/packages/driver/types/cy/logGroup.d.ts b/packages/driver/types/cy/logGroup.d.ts new file mode 100644 index 000000000000..1d2f1a73b7b9 --- /dev/null +++ b/packages/driver/types/cy/logGroup.d.ts @@ -0,0 +1,27 @@ +// The type declarations for Cypress Log Group & the corresponding configuration permutations +declare namespace Cypress { + declare namespace LogGroup { + type ApiCallback = (log: Cypress.Log) => Chainable + type LogGroup = (cypress: Cypress.Cypress, options: Partial, callback: LogGroupCallback) => Chainable + + interface Config { + // the JQuery element for the command. This will highlight the command + // in the main window when debugging + $el?: JQuery + // whether or not to emit a log to the UI + // when disabled, child logs will not be nested in the UI + log?: boolean + // name of the group - defaults to current command's name + name?: string + // additional information to include in the log + message?: string + // timeout of the group command - defaults to defaultCommandTimeout + timeout?: number + // the type of log + // system - log generated by Cypress + // parent - log generated by Command + // child - log generated by Chained Command + type?: Cypress.InternalLogConfig['type'] + } + } +} diff --git a/packages/driver/types/cypress/log.d.ts b/packages/driver/types/cypress/log.d.ts new file mode 100644 index 000000000000..877f729a375c --- /dev/null +++ b/packages/driver/types/cypress/log.d.ts @@ -0,0 +1,119 @@ +// The type declarations for Cypress Logs & the corresponding configuration permutations +declare namespace Cypress { + interface Cypress { + log(options: Partial): Log | undefined + } + + interface Log extends Log { + set(key: K, value: LogConfig[K]): InternalLog + set(options: Partial) + groupEnd(): void + } + + type ReferenceAlias = { + cardinal: number, + name: string, + ordinal: string, + } + + type Snapshot = { + body?: {get: () => any}, + htmlAttrs?: {[key: string]: any}, + name?: string + } + + type ConsoleProps = { + Command?: string + Snapshot?: string + Elements?: number + Selector?: string + Yielded?: HTMLElement + Event?: string + Message?: string + actual?: any + expected?: any + } + + type RenderProps = { + indicator?: 'aborted' | 'pending' | 'successful' | 'bad' + message?: string + } + + interface InternalLogConfig { + // the JQuery element for the command. This will highlight the command + // in the main window when debugging + $el?: JQuery | string + alias?: string + aliasType?: 'agent' | 'route' | 'primitive' | 'dom' | undefined + browserPreRequest?: any + callCount?: number + chainerId?: string + commandName?: string + // provide the content to display in the dev tool's console when a log is + // clicked from the Reporter's Command Log + consoleProps?: () => Command | Command + coords?: { + left: number + leftCenter: number + top: number + topCenter: number + x: number + y: number + } + count?: number + // the name override for display purposes only + displayName?: string + // whether or not to show the log in the Reporter UI or only + // store the log details on the command and log manager + emitOnly?: boolean + end?: boolean + ended?: boolean + err?: Error + error?: Error + // whether or not the generated log was an event or command + event?: boolean + expected?: string + functionName?: string + // whether or not to start a new log group + groupStart?: boolean + hookId?: number + id?: string + // defaults to command + instrument?: 'agent' | 'command' | 'route' + // whether or not the xhr route had a corresponding response stubbed out + isStubbed?: boolean + // additional information to include in the log if not overridden + // the render props message + // defaults to command arguments for command instrument + message?: string | Array | any[] + method?: string + // name of the log + name?: string + numElements?: number + // the number of xhr responses that occurred. This is only applicable to + // logs defined with instrument=route + numResponses?: number + referencesAlias?: ReferenceAlias[] + renderProps?: () => RenderProps | RenderProps + response?: string | object + selector?: any + snapshot?: boolean + snapshots?: [] + state?: "failed" | "passed" | "pending" // representative of Mocha.Runnable.constants (not publicly exposed by Mocha types) + status?: number + testCurrentRetry?: number + testId?: string + // timeout of the group command - defaults to defaultCommandTimeout + timeout?: number + // the type of log + // system - log generated by Cypress + // parent - log generated by Command + // child - log generated by Chained Command + type?: 'system' | 'parent' | 'child' | ((current: State['state']['current'], subject: State['state']['subject']) => 'parent' | 'child') + url?: string + viewportHeight?: number + viewportWidth?: number + visible?: boolean + wallClockStartedAt?: string + } +} diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index 87c3de861e8c..fafa875ac088 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -1,5 +1,7 @@ // NOTE: this is for internal Cypress types that we don't want exposed in the public API but want for development // TODO: find a better place for this +/// +/// interface InternalWindowLoadDetails { type: 'same:domain' | 'cross:domain' | 'cross:domain:failure' @@ -73,97 +75,14 @@ declare namespace Cypress { warning: (message: string) => void } - type Log = ReturnType - - type ReferenceAlias = { - cardinal: number, - name: string, - ordinal: string, - } - - type Snapshot = { - body?: {get: () => any}, - htmlAttrs?: {[key: string]: any}, - name?: string - } - - type ConsoleProps = { - Command?: string - Snapshot?: string - Elements?: number - Selector?: string - Yielded?: HTMLElement - Event?: string - Message?: string - actual?: any - expected?: any - } - - type RenderProps = { - indicator?: 'aborted' | 'pending' | 'successful' | 'bad' - message?: string - } - - interface LogConfig { - $el?: jQuery | string - message: any - instrument?: 'route' | 'command' - isStubbed?: boolean - alias?: string - aliasType?: 'route' - referencesAlias?: ReferenceAlias[] - chainerId?: string - commandName?: string - coords?: { - left: number - leftCenter: number - top: number - topCenter: number - x: number - y: number - } - count?: number - callCount?: number - type?: 'parent' | 'child' - event?: boolean - end?: boolean - ended?: boolean - expected?: string - functionName?: string - name?: string - id?: string - hookId?: number - method?: string - url?: string - status?: number - state?: "failed" | "passed" | "pending" // representative of Mocha.Runnable.constants (not publicly exposed by Mocha types) - numResponses?: number - numElements?: number - numResponses?: number - response?: string | object - testCurrentRetry?: number - timeout?: number - testId?: string - err?: Error - error?: Error - snapshot?: boolean - snapshots?: [] - selector?: any - viewportHeight?: number - viewportWidth?: number - visible?: boolean - wallClockStartedAt?: string - renderProps?: () => RenderProps | RenderProps - consoleProps?: () => Command | Command - browserPreRequest?: any - } - + // Extend Cypress.state properties here interface State { (k: '$autIframe', v?: JQuery): JQuery | undefined (k: 'routes', v?: RouteMap): RouteMap (k: 'aliasedRequests', v?: AliasedRequest[]): AliasedRequest[] (k: 'document', v?: Document): Document (k: 'window', v?: Window): Window + (k: 'logGroupIds', v?: Array): Array (k: string, v?: any): any state: Cypress.state } @@ -172,7 +91,6 @@ declare namespace Cypress { (k: keyof ResolvedConfigOptions, v?: any): any } - // Extend Cypress.state properties here interface ResolvedConfigOptions { $autIframe: JQuery document: Document diff --git a/packages/reporter/cypress.json b/packages/reporter/cypress.json index bbf940fae4c6..8110d4801e99 100644 --- a/packages/reporter/cypress.json +++ b/packages/reporter/cypress.json @@ -2,7 +2,7 @@ "projectId": "ypt4pf", "baseUrl": "http://localhost:5006", "viewportWidth": 400, - "viewportHeight": 450, + "viewportHeight": 1000, "reporter": "../../node_modules/cypress-multi-reporters/index.js", "reporterOptions": { "configFile": "../../mocha-reporter-config.json" diff --git a/packages/reporter/cypress/fixtures/runnables_commands.json b/packages/reporter/cypress/fixtures/runnables_commands.json index 141df3b6bc60..f5a6e13a2572 100644 --- a/packages/reporter/cypress/fixtures/runnables_commands.json +++ b/packages/reporter/cypress/fixtures/runnables_commands.json @@ -154,6 +154,18 @@ "testId": "r3", "timeout": 4000, "type": "parent" + }, + { + "hookId": "r3", + "id": 240, + "instrument": "command", + "message": "System Event Command", + "name": "cmd", + "state": "passed", + "event": true, + "testId": "r3", + "timeout": 4000, + "type": "system" } ], "invocationDetails": { diff --git a/packages/reporter/cypress/integration/aliases_spec.ts b/packages/reporter/cypress/integration/aliases_spec.ts index 530e8eba246c..67566c599a0e 100644 --- a/packages/reporter/cypress/integration/aliases_spec.ts +++ b/packages/reporter/cypress/integration/aliases_spec.ts @@ -281,7 +281,7 @@ describe('aliases', () => { cy.get('.command-wrapper') .first() .within(() => { - cy.get('.num-children').should('not.be.visible') + cy.get('.num-children').should('not.exist') cy.contains('.command-interceptions', 'getPosts') }) @@ -535,7 +535,7 @@ describe('aliases', () => { cy.get('.command-wrapper') .first() .within(() => { - cy.get('.num-children').should('not.be.visible') + cy.get('.num-children').should('not.exist') cy.contains('.command-alias', 'dropdown') }) diff --git a/packages/reporter/cypress/integration/commands_spec.ts b/packages/reporter/cypress/integration/commands_spec.ts index 8a3d90ab591a..b0cf07aad371 100644 --- a/packages/reporter/cypress/integration/commands_spec.ts +++ b/packages/reporter/cypress/integration/commands_spec.ts @@ -42,7 +42,143 @@ describe('commands', () => { }) it('displays all the commands', () => { - cy.get('.command').should('have.length', 9) + addCommand(runner, { + id: 102, + name: 'get', + message: '#element', + state: 'passed', + timeout: 4000, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 124, + name: 'within', + state: 'passed', + type: 'child', + timeout: 4000, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 125, + name: 'get', + message: '#my_element', + state: 'passed', + timeout: 4000, + group: 124, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 129, + name: 'within', + state: 'passed', + type: 'child', + group: 124, + timeout: 4000, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 130, + name: 'get', + message: '#my_element that _has_ a really long message to show **wrapping** works as expected', + state: 'passed', + timeout: 4000, + groupLevel: 1, + group: 129, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 1229, + name: 'within', + state: 'passed', + type: 'child', + group: 130, + groupLevel: 1, + timeout: 4000, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 1311, + name: 'get', + message: '#my_element_nested', + state: 'passed', + timeout: 4000, + groupLevel: 2, + group: 1229, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 1291, + name: 'assert', + type: 'child', + message: 'has class named .omg', + state: 'passed', + timeout: 4000, + group: 1229, + groupLevel: 2, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 1291, + name: 'log', + message: 'do something else', + state: 'passed', + timeout: 4000, + group: 130, + groupLevel: 1, + wallClockStartedAt: inProgressStartedAt, + }) + + addCommand(runner, { + id: 135, + name: 'and', + type: 'child', + message: 'has class named .lets-roll', + state: 'passed', + timeout: 4000, + group: 124, + wallClockStartedAt: inProgressStartedAt, + }) + + const indicators = ['successful', 'pending', 'aborted', 'bad'] + + indicators.forEach((indicator, index) => { + addCommand(runner, { + id: 1600 + index, + name: 'xhr', + event: true, + state: 'passed', + timeout: 4000, + renderProps: { + indicator, + message: `${indicator} indicator`, + }, + wallClockStartedAt: inProgressStartedAt, + }) + }) + + const assertStates = ['passed', 'pending', 'failed'] + + assertStates.forEach((state, index) => { + addCommand(runner, { + id: 1700 + index, + name: 'assert', + type: 'child', + message: 'expected **element** to have length of **16** but got **12** instead', + state, + timeout: 4000, + wallClockStartedAt: inProgressStartedAt, + }) + }) + + cy.get('.command').should('have.length', 27) cy.percySnapshot() }) @@ -61,6 +197,12 @@ describe('commands', () => { }) it('includes the state class', () => { + addCommand(runner, { + name: 'log', + message: 'command-warning-state', + state: 'warning', + }) + cy.contains('#exists').closest('.command') .should('have.class', 'command-state-passed') @@ -69,6 +211,9 @@ describe('commands', () => { cy.contains('#in-progress').closest('.command') .should('have.class', 'command-state-pending') + + cy.contains('command-warning-state').closest('.command') + .should('have.class', 'command-state-warning') }) it('displays the number', () => { @@ -114,13 +259,42 @@ describe('commands', () => { }) it('shows indicator when specified', () => { - cy.contains('GET ---').closest('.command').find('.command-message .fa-circle') - .should('be.visible') + const indicators = ['successful', 'pending', 'aborted', 'bad'] + + indicators.forEach((indicator) => { + addCommand(runner, { + name: 'xhr', + event: true, + renderProps: { + indicator, + message: `${indicator} indicator`, + }, + }) + }) + + indicators.forEach((indicator) => { + cy.contains(`${indicator} indicator`).closest('.command').find('.command-message .fa-circle') + .should('be.visible') + }) + + cy.percySnapshot() }) - it('includes the renderProps indicator as a class name when specified', () => { - cy.contains('Lorem ipsum').closest('.command').find('.command-message .fa-circle') - .should('have.class', 'bad') + it('assert commands for each state', () => { + const assertStates = ['passed', 'pending', 'failed'] + + assertStates.forEach((state) => { + addCommand(runner, { + name: 'assert', + type: 'child', + message: `expected **element** to have **state of ${state}**`, + state, + }) + }) + + cy.get('.command').should('have.length', 13) + + cy.percySnapshot() }) describe('progress bar', () => { @@ -218,14 +392,16 @@ describe('commands', () => { }) }) - context('duplicates', () => { - it('collapses consecutive duplicate events into one', () => { + context('event duplicates', () => { + it('collapses consecutive duplicate events into group', () => { cy.get('.command-name-xhr').should('have.length', 3) }) it('displays number of duplicates', () => { cy.contains('GET --- /dup').closest('.command').find('.num-children') .should('have.text', '4') + .trigger('mouseover') + .get('.cy-tooltip').should('have.text', 'This event occurred 4 times') }) it('expands all events after clicking arrow', () => { @@ -328,6 +504,133 @@ describe('commands', () => { }) }) + context('command group', () => { + let groupId + + beforeEach(() => { + groupId = addCommand(runner, { + name: 'group', + message: 'example group command', + type: 'parent', + }) + + addCommand(runner, { + name: 'get', + message: '#my_nested_element', + group: groupId, + }) + }) + + it('group is open by default when all nested command have passed', () => { + addCommand(runner, { + name: 'log', + message: 'chained log example', + }) + + cy.contains('chained log example') // ensure test content has loaded + + cy.get('.command-name-group') + .should('have.class', 'command-is-open') + .find('.command-expander') + .should('be.visible') + .closest('.command-name-group') + .click() + + cy.get('.command-name-group') + .should('not.have.class', 'command-is-open') + + cy.get('.command-name-group') + .find('.num-children') + .should('have.text', '1') + .trigger('mouseover') + .get('.cy-tooltip').should('have.text', '1 log currently hidden') + .percySnapshot() + }) + + it('group is open by default when last nested command failed', () => { + addCommand(runner, { + name: 'log', + message: 'chained log example', + state: 'failed', + group: groupId, + }) + + cy.contains('chained log example') // ensure test content has loaded + + cy.get('.command-name-group') + .should('have.class', 'command-is-open') + .find('.command-expander') + .should('be.visible') + .closest('.command-name-group') + .click() + + cy.get('.command-name-group') + .find('.num-children') + .should('not.exist') + .percySnapshot() + }) + + it('clicking opens and closes the group', () => { + cy.get('.command-name-group') + .find('.num-children') + .should('not.exist') + + cy.get('.command-name-group') + .should('have.class', 'command-is-open') + .find('.command-expander') + .should('be.visible') + .closest('.command-name-group') + .click() + + cy.get('.command-name-group') + .find('.num-children') + .should('be.visible') + .should('have.text', '1') + + cy.get('.command-name-group') + .should('not.have.class', 'command-is-open') + }) + + it('displays with nested logs', () => { + const nestedGroupId = addCommand(runner, { + name: 'group-2', + state: 'passed', + type: 'child', + group: groupId, + }) + + addCommand(runner, { + name: 'get', + message: '#my_element_nested', + state: 'passed', + group: nestedGroupId, + }) + + addCommand(runner, { + name: 'assert', + type: 'child', + message: 'has class named .omg', + group: nestedGroupId, + }) + + addCommand(runner, { + name: 'log', + message: 'chained log example', + state: 'passed', + group: groupId, + }) + + cy.get('.command-name-group') + .should('have.class', 'command-is-open') + + cy.get('.command-name-group-2') + .should('have.class', 'command-is-open') + .click() + + cy.percySnapshot() + }) + }) + context('studio commands', () => { beforeEach(() => { addCommand(runner, { diff --git a/packages/reporter/cypress/integration/unit/command_model_spec.ts b/packages/reporter/cypress/integration/unit/command_model_spec.ts index 69cc66b175d6..e3b159f90b8d 100644 --- a/packages/reporter/cypress/integration/unit/command_model_spec.ts +++ b/packages/reporter/cypress/integration/unit/command_model_spec.ts @@ -27,6 +27,110 @@ describe('Command model', () => { clock.restore() }) + context('.visible', () => { + let command: CommandModel + + it('sets visible to true for command has visible elements associated to it', () => { + command = new CommandModel(commandProps({ visible: true })) + expect(command.visible).to.be.true + }) + + it('sets visible to false for command has hidden elements associated to it', () => { + command = new CommandModel(commandProps({ visible: false })) + expect(command.visible).to.be.false + }) + + it('sets visible to true for command that does not associate with visibility', () => { + command = new CommandModel(commandProps({ visible: undefined })) + expect(command.visible).to.be.true + }) + }) + + context('.numChildren', () => { + context('event log', () => { + it('with no children', () => { + const command = new CommandModel(commandProps({ event: true })) + + expect(command.numChildren).to.eq(1) + }) + + it('with children', () => { + const command = new CommandModel(commandProps({ event: true })) + + command.addChild(new CommandModel(commandProps())) + expect(command.numChildren).to.eq(2) + + command.addChild(new CommandModel(commandProps())) + expect(command.numChildren).to.eq(3) + }) + }) + + context('command log', () => { + it('with no children', () => { + const command = new CommandModel(commandProps({})) + + expect(command.numChildren).to.eq(0) + }) + + it('with children', () => { + const command = new CommandModel(commandProps({})) + + command.addChild(new CommandModel(commandProps())) + expect(command.numChildren).to.eq(1) + + command.addChild(new CommandModel(commandProps())) + expect(command.numChildren).to.eq(2) + }) + + it('with children that are a command group', () => { + const command = new CommandModel(commandProps({})) + + command.addChild(new CommandModel(commandProps())) + + const commandGroup = new CommandModel(commandProps()) + + commandGroup.addChild(new CommandModel(commandProps())) + commandGroup.addChild(new CommandModel(commandProps())) + + command.addChild(commandGroup) + expect(command.numChildren).to.eq(4) + }) + }) + }) + + context('.hasChildren', () => { + context('event log', () => { + it('with no children', () => { + const command = new CommandModel(commandProps({ event: true })) + + expect(command.hasChildren).to.be.false + }) + + it('with one or more children', () => { + const command = new CommandModel(commandProps({ event: true })) + + command.addChild(new CommandModel(commandProps())) + + expect(command.hasChildren).to.be.true + }) + }) + + context('command log', () => { + it('with no children', () => { + const command = new CommandModel(commandProps({})) + + expect(command.hasChildren).to.be.false + }) + + it('with one or more children', () => { + const command = new CommandModel(commandProps({})) + + command.addChild(new CommandModel(commandProps())) + expect(command.hasChildren).to.be.true + }) + }) + }) + context('.isLongRunning', () => { describe('when model is pending on initialization and LONG_RUNNING_THRESHOLD passes', () => { let command: CommandModel @@ -46,26 +150,26 @@ describe('Command model', () => { expect(command.isLongRunning).to.be.false }) }) - }) - describe('when model is not pending on initialization, is updated to pending, and LONG_RUNNING_THRESHOLD passes', () => { - let command: CommandModel + describe('when model is not pending on initialization, is updated to pending, and LONG_RUNNING_THRESHOLD passes', () => { + let command: CommandModel - beforeEach(() => { - command = new CommandModel(commandProps({ state: null })) - clock.tick(300) - command.update({ state: 'pending' } as CommandProps) - }) + beforeEach(() => { + command = new CommandModel(commandProps({ state: null })) + clock.tick(300) + command.update({ state: 'pending' } as CommandProps) + }) - it('sets isLongRunning to true if model is still pending', () => { - clock.tick(LONG_RUNNING_THRESHOLD) - expect(command.isLongRunning).to.be.true - }) + it('sets isLongRunning to true if model is still pending', () => { + clock.tick(LONG_RUNNING_THRESHOLD) + expect(command.isLongRunning).to.be.true + }) - it('does not set isLongRunning to true if model is no longer pending', () => { - command.state = 'passed' - clock.tick(LONG_RUNNING_THRESHOLD) - expect(command.isLongRunning).to.be.false + it('does not set isLongRunning to true if model is no longer pending', () => { + command.state = 'passed' + clock.tick(LONG_RUNNING_THRESHOLD) + expect(command.isLongRunning).to.be.false + }) }) }) }) diff --git a/packages/reporter/cypress/support/utils.ts b/packages/reporter/cypress/support/utils.ts index 758f21600fe3..8f1a0db83702 100644 --- a/packages/reporter/cypress/support/utils.ts +++ b/packages/reporter/cypress/support/utils.ts @@ -158,10 +158,16 @@ export const addCommand = (runner: EventEmitter, log: Partial) => state: 'passed', testId: 'r3', testCurrentRetry: 0, + timeout: 4000, type: 'parent', url: 'http://example.com', hasConsoleProps: true, } - runner.emit('reporter:log:add', Object.assign(defaultLog, log)) + const commandLog = Object.assign(defaultLog, log) + + runner.emit('reporter:log:add', commandLog) + + // return command log id to enable adding new command to command group + return commandLog.id } diff --git a/packages/reporter/src/commands/command-model.ts b/packages/reporter/src/commands/command-model.ts index ec5ed9b21200..387bd2148039 100644 --- a/packages/reporter/src/commands/command-model.ts +++ b/packages/reporter/src/commands/command-model.ts @@ -9,7 +9,7 @@ const LONG_RUNNING_THRESHOLD = 1000 interface RenderProps { message?: string - indicator?: string + indicator?: 'successful' | 'pending' | 'aborted' | 'bad' interceptions?: Array<{ command: 'intercept' | 'route' alias?: string @@ -34,7 +34,6 @@ export interface CommandProps extends InstrumentProps { group?: number hasSnapshot?: boolean hasConsoleProps?: boolean - } export default class Command extends Instrument { @@ -45,10 +44,9 @@ export default class Command extends Instrument { @observable number?: number @observable numElements: number @observable timeout?: number - @observable visible?: boolean = true + @observable visible?: boolean @observable wallClockStartedAt?: string @observable children: Array = [] - @observable isChild = false @observable hookId: string @observable isStudio: boolean @observable showError?: boolean = false @@ -64,9 +62,21 @@ export default class Command extends Instrument { return this.renderProps.message || this.message } + private countNestedCommands (children) { + if (children.length === 0) { + return 0 + } + + return children.length + children.reduce((previousValue, child) => previousValue + this.countNestedCommands(child.children), 0) + } + @computed get numChildren () { - // and one to include self so it's the total number of same events - return this.children.length + 1 + if (this.event) { + // add one to include self so it's the total number of same events + return this.children.length + 1 + } + + return this.countNestedCommands(this.children) } @computed get isOpen () { @@ -75,6 +85,7 @@ export default class Command extends Instrument { return this._isOpen || (this._isOpen === null && ( (this.group && this.type === 'system' && this.hasChildren) || + (this.hasChildren && !this.event && this.type !== 'system') || _.some(this.children, (v) => v.hasChildren) || _.last(this.children)?.isOpen || (_.some(this.children, (v) => v.isLongRunning) && _.last(this.children)?.state === 'pending') || @@ -88,7 +99,13 @@ export default class Command extends Instrument { } @computed get hasChildren () { - return this.numChildren > 1 + if (this.event) { + // if the command is an event log, we add one to the number of children count to include + // itself in the total number of same events that render when the group is closed + return this.numChildren > 1 + } + + return this.numChildren > 0 } constructor (props: CommandProps) { @@ -100,15 +117,16 @@ export default class Command extends Instrument { this.numElements = props.numElements this.renderProps = props.renderProps || {} this.timeout = props.timeout - this.visible = props.visible + // 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 this.wallClockStartedAt = props.wallClockStartedAt this.hookId = props.hookId this.isStudio = !!props.isStudio - this.showError = props.showError + this.showError = !!props.showError this.group = props.group - this.hasSnapshot = props.hasSnapshot - this.hasConsoleProps = props.hasConsoleProps - + this.hasSnapshot = !!props.hasSnapshot + this.hasConsoleProps = !!props.hasConsoleProps this._checkLongRunning() } @@ -119,7 +137,9 @@ export default class Command extends Instrument { this.event = props.event this.numElements = props.numElements this.renderProps = props.renderProps || {} - this.visible = props.visible + // 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 this.timeout = props.timeout this.hasSnapshot = props.hasSnapshot this.hasConsoleProps = props.hasConsoleProps @@ -142,7 +162,6 @@ export default class Command extends Instrument { } addChild (command: Command) { - command.isChild = true command.setGroup(this.id) this.children.push(command) } diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index ccfea3132e43..019fdd0b8b7c 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -22,13 +22,22 @@ const md = new Markdown() const displayName = (model: CommandModel) => model.displayName || model.name const nameClassName = (name: string) => name.replace(/(\s+)/g, '-') const formattedMessage = (message: string) => message ? md.renderInline(message) : '' -const visibleMessage = (model: CommandModel) => { - if (model.visible) return '' +const invisibleMessage = (model: CommandModel) => { + if (model.visible) { + return '' + } return model.numElements > 1 ? 'One or more matched elements are not visible.' : 'This element is not visible.' } +const numberOfChildrenMessage = (numChildren, event?: boolean) => { + if (event) { + return `This event occurred ${numChildren} times` + } + + return `${numChildren} ${numChildren > 1 ? 'logs' : 'log'} currently hidden` +} const shouldShowCount = (aliasesWithDuplicates: Array | null, aliasName: Alias, model: CommandModel) => { if (model.aliasType !== 'route') { @@ -216,17 +225,22 @@ class Command extends Component { return } + const commandName = model.name ? nameClassName(model.name) : '' + const isSystemEvent = model.type === 'system' && model.event + const isSessionCommand = commandName === 'session' + const displayNumOfChildren = !isSystemEvent && !isSessionCommand && model.hasChildren && !model.isOpen + return (
  • { 'command-has-console-props': model.hasConsoleProps, 'multiple-elements': model.numElements > 1, 'command-has-children': model.hasChildren, - 'command-is-child': model.isChild, 'command-is-open': this._isOpen(), }, )} @@ -258,9 +271,11 @@ class Command extends Component { {model.number || ''} - - - + {!model.hasChildren && ( + + + + )} {model.event && model.type !== 'system' ? `(${displayName(model)})` : displayName(model)} @@ -269,7 +284,7 @@ class Command extends Component { - + @@ -278,9 +293,11 @@ class Command extends Component { - - 1 })}>{model.numChildren} - + {displayNumOfChildren && ( + + 1 })}>{model.numChildren} + + )} @@ -376,6 +393,13 @@ class Command extends Component { _snapshot (show: boolean) { const { model, runnablesStore } = this.props + // do not trigger the show:snapshot event for commands groups + // TODO: remove this behavior in 10.0+ when a group + // can both be expanded and collapsed and pinned + if (model.hasChildren) { + return + } + if (show) { runnablesStore.attemptingShowSnapshot = true diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 2e4574d30213..a45fc013a234 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -10,6 +10,7 @@ } .command { + background-color: #eef1f4; cursor: default; margin: 0; } @@ -82,23 +83,12 @@ border-top: 0; } - .command-type-child { - .command-method { - &:before { - float: left; - content: "-"; - margin-right: 2px; - padding-left: 5px; - } - } - } - - .command-type-system.command-is-event, - .command-name-session { - > span > .command-wrapper { - .num-children { - display: none; - } + .command-type-child > span > div > div > .command-method { + &:before { + float: left; + content: "-"; + margin-right: 2px; + padding-left: 5px; } } @@ -527,18 +517,12 @@ font-style: normal; } - .command-has-num-elements .num-elements, - .num-children, - .command-has-children.command-is-open - > span - > .command-wrapper - .num-children.has-children { + .command-has-num-elements .num-elements { display: none; } .command-has-num-elements.no-elements .num-elements, - .command-has-num-elements.multiple-elements .num-elements, - .command-has-children .num-children.has-children { + .command-has-num-elements.multiple-elements .num-elements { display: inline; } diff --git a/packages/reporter/src/lib/shared.scss b/packages/reporter/src/lib/shared.scss index 5bb36f26f568..a8ec3ce7b877 100644 --- a/packages/reporter/src/lib/shared.scss +++ b/packages/reporter/src/lib/shared.scss @@ -6,7 +6,6 @@ .num-children { border-radius: 5px; color: #fff; - display: none; font-size: 85%; line-height: 1; margin-left: 5px; @@ -18,6 +17,7 @@ .num-elements { background-color: #ababab; + display: none; } .num-children {