diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index d7ffbd40a63e..f1b4bbc4d267 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -12,10 +12,14 @@ _Released 01/31/2023 (PENDING)_ - Fixed an issue where alternative Microsoft Edge Beta, Canary, and Dev binary versions were not being discovered by Cypress. Fixes [#25455](https://github.com/cypress-io/cypress/issues/25455). +**Performance:** + +- Improved memory consumption in `run` mode by removing reporter logs for successful tests. + Fixes [#25230](https://github.com/cypress-io/cypress/issues/25230). + **Dependency Updates:** - Upgraded [`underscore.string`](https://github.com/esamattis/underscore.string/blob/HEAD/CHANGELOG.markdown) from `3.3.5` to `3.3.6` to reference rebuilt assets after security patch to fix regular expression DDOS exploit. - Fixed in [#25574](https://github.com/cypress-io/cypress/pull/25574). ## 12.4.1 diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 2d0ded980238..19dd4f43e113 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -551,7 +551,7 @@ class $Cypress { // this event is how the reporter knows how to display // stats and runnable properties such as errors - this.emit('test:after:run', ...args) + this.emit('test:after:run', args[0], this.config('isInteractive')) this.maybeEmitCypressInCypress('mocha', 'test:after:run', args[0]) if (this.config('isTextTerminal')) { diff --git a/packages/reporter/cypress/e2e/memory.cy.ts b/packages/reporter/cypress/e2e/memory.cy.ts new file mode 100644 index 000000000000..0e11fc21e462 --- /dev/null +++ b/packages/reporter/cypress/e2e/memory.cy.ts @@ -0,0 +1,234 @@ +import { EventEmitter } from 'events' +import { RootRunnable } from '../../src/runnables/runnables-store' +import { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store' + +let runner: EventEmitter +let runnables: RootRunnable +const { _ } = Cypress + +function visitAndRenderReporter (studioEnabled: boolean = false, studioActive: boolean = false) { + cy.fixture('runnables_memory').then((_runnables) => { + runnables = _runnables + }) + + runner = new EventEmitter() + + const runnerStore = new MobxRunnerStore('e2e') + + runnerStore.setSpec({ + name: 'foo.js', + relative: 'relative/path/to/foo.js', + absolute: '/absolute/path/to/foo.js', + }) + + cy.visit('/').then((win) => { + win.render({ + studioEnabled, + runner, + runnerStore, + }) + }) + + cy.get('.reporter').then(() => { + runner.emit('runnables:ready', runnables) + runner.emit('reporter:start', { studioActive }) + }) + + return runnerStore +} + +describe('tests', () => { + beforeEach(() => { + visitAndRenderReporter() + }) + + context('run mode', () => { + beforeEach(() => { + _.each(runnables.suites, (suite) => { + _.each(suite.tests, (test) => { + runner.emit('test:after:run', test, false) + }) + }) + }) + + it('clears logs for a collapsed test', () => { + cy.contains('passed') + .as('passed') + .closest('.runnable') + .should('have.class', 'test') + .find('.runnable-instruments').should('not.exist') + + cy.get('@passed').click() + + cy.get('@passed') + .parents('.collapsible').first() + .find('.attempt-item').eq(0) + .contains('No commands were issued in this test.') + + cy.percySnapshot() + }) + + it('retains logs for an expanded test', () => { + cy.contains('failed') + .parents('.collapsible').first() + .should('have.class', 'is-open') + .find('.collapsible-content') + .should('be.visible') + + cy.contains('failed') + .parents('.collapsible').first() + .find('.attempt-item') + .eq(0) + .find('.attempt-1') + .within(() => { + cy.get('.sessions-container') + cy.get('.runnable-agents-region') + cy.get('.runnable-routes-region') + cy.get('.runnable-commands-region') + }) + + cy.percySnapshot() + }) + + it('retains logs for failed attempt and clears logs for passed attempt after retry', () => { + cy.contains('passed after retry') + .parents('.collapsible').first() + .should('not.have.class', 'is-open') + .find('.collapsible-content') + .should('not.exist') + + cy.contains('passed after retry').click() + + cy.contains('passed after retry') + .parents('.collapsible').first() + .find('.attempt-item').as('attempts') + + cy.get('@attempts').eq(0).as('firstAttempt') + .find('.collapsible') + .should('not.have.class', 'is-open') + .find('.collapsible-indicator').should('not.exist') + + cy.get('@firstAttempt') + .contains('Attempt 1') + .click() + + cy.get('@firstAttempt') + .find('.attempt-1') + .within(() => { + cy.get('.sessions-container') + cy.get('.runnable-agents-region') + cy.get('.runnable-routes-region') + cy.get('.runnable-commands-region') + }) + + cy.get('@attempts').eq(1).as('secondAttempt') + .find('.collapsible') + .should('have.class', 'is-open') + .find('.collapsible-indicator').should('not.exist') + + cy.get('@secondAttempt') + .contains('No commands were issued in this test.') + + cy.percySnapshot() + }) + + it('retains logs for failed attempts', () => { + cy.contains('failed with retries') + .parents('.collapsible').first() + .find('.attempt-item').as('attempts') + + cy.get('@attempts').eq(0).as('firstAttempt') + .find('.collapsible') + .should('not.have.class', 'is-open') + .find('.collapsible-indicator').should('not.exist') + + cy.get('@firstAttempt') + .contains('Attempt 1') + .click() + + cy.get('@firstAttempt') + .find('.attempt-1') + .within(() => { + cy.get('.sessions-container') + cy.get('.runnable-agents-region') + cy.get('.runnable-routes-region') + cy.get('.runnable-commands-region') + }) + + cy.get('@attempts').eq(1).as('secondAttempt') + .find('.collapsible') + .should('have.class', 'is-open') + .find('.collapsible-content') + .should('be.visible') + + cy.get('@secondAttempt') + .find('.attempt-2') + .within(() => { + cy.get('.sessions-container') + cy.get('.runnable-agents-region') + cy.get('.runnable-routes-region') + cy.get('.runnable-commands-region') + }) + + cy.contains('failed with retries') + .scrollIntoView() + .percySnapshot() + }) + }) + + context('open mode', () => { + beforeEach(() => { + _.each(runnables.suites, (suite) => { + _.each(suite.tests, (test) => { + runner.emit('test:after:run', test, true) + }) + }) + }) + + it('retains logs for a collapsed test', () => { + cy.contains('passed') + .as('passed') + .closest('.runnable') + .should('have.class', 'test') + .find('.runnable-instruments').should('not.exist') + + cy.get('@passed').click() + + cy.get('@passed') + .parents('.collapsible').first() + .find('.attempt-item') + .eq(0) + .find('.attempt-1') + .within(() => { + cy.get('.sessions-container') + cy.get('.runnable-agents-region') + cy.get('.runnable-routes-region') + cy.get('.runnable-commands-region') + }) + + cy.percySnapshot() + }) + + it('retains logs for an expanded test', () => { + cy.contains('failed') + .parents('.collapsible').first() + .should('have.class', 'is-open') + .find('.collapsible-content') + .should('be.visible') + + cy.contains('failed') + .parents('.collapsible').first() + .find('.attempt-item') + .eq(0) + .find('.attempt-1') + .within(() => { + cy.get('.sessions-container') + cy.get('.runnable-agents-region') + cy.get('.runnable-routes-region') + cy.get('.runnable-commands-region') + }) + + cy.percySnapshot() + }) + }) +}) diff --git a/packages/reporter/cypress/e2e/unit/test_model.cy.ts b/packages/reporter/cypress/e2e/unit/test_model.cy.ts index 2d6f0f03c4d6..f520c0b2d52e 100644 --- a/packages/reporter/cypress/e2e/unit/test_model.cy.ts +++ b/packages/reporter/cypress/e2e/unit/test_model.cy.ts @@ -101,7 +101,7 @@ describe('Test model', () => { command.isLongRunning = true - test.finish({} as UpdatableTestProps) + test.finish({} as UpdatableTestProps, false) expect(test.isLongRunning).to.be.false }) }) @@ -282,21 +282,21 @@ describe('Test model', () => { it('sets the test as inactive', () => { const test = createTest() - test.finish({} as UpdatableTestProps) + test.finish({} as UpdatableTestProps, false) expect(test.isActive).to.be.false }) it('updates the state of the test', () => { const test = createTest() - test.finish({ state: 'failed' } as UpdatableTestProps) + test.finish({ state: 'failed' } as UpdatableTestProps, false) expect(test.state).to.equal('failed') }) it('updates the test err', () => { const test = createTest() - test.finish({ err: { name: 'SomeError' } as Err } as UpdatableTestProps) + test.finish({ err: { name: 'SomeError' } as Err } as UpdatableTestProps, false) expect(test.err.name).to.equal('SomeError') }) @@ -304,7 +304,7 @@ describe('Test model', () => { const test = createTest({ hooks: [{ hookId: 'h1', hookName: 'before each' }] }) test.addLog(createCommand({ instrument: 'command' })) - test.finish({ failedFromHookId: 'h1', err: { message: 'foo' } as Err } as UpdatableTestProps) + test.finish({ state: 'failed', failedFromHookId: 'h1', err: { message: 'foo' } as Err } as UpdatableTestProps, false) expect(test.lastAttempt.hooks[1].failed).to.be.true }) @@ -312,7 +312,7 @@ describe('Test model', () => { const test = createTest() expect(() => { - test.finish({ hookId: 'h1' } as UpdatableTestProps) + test.finish({ hookId: 'h1' } as UpdatableTestProps, false) }).not.to.throw() }) }) diff --git a/packages/reporter/cypress/fixtures/runnables_memory.json b/packages/reporter/cypress/fixtures/runnables_memory.json new file mode 100644 index 000000000000..b2cc249c667e --- /dev/null +++ b/packages/reporter/cypress/fixtures/runnables_memory.json @@ -0,0 +1,484 @@ +{ + "id": "r1", + "title": "", + "root": true, + "hooks": [], + "tests": [], + "suites": [ + { + "id": "r2", + "title": "suite 1", + "root": false, + "hooks": [], + "tests": [ + { + "id": "r3", + "title": "passed", + "state": "passed", + "hooks": [ + { + "title": "\"before each\" hook", + "hookName": "before each", + "hookId": "h1", + "pending": false, + "body": "() => {\\n cy.session('test', () => {});\\n }", + "type": "hook", + "currentRetry": 0, + "retries": -1 + } + ], + "agents": [ + { + "id": 1, + "functionName": "get", + "name": "spy", + "alias": "getAlias", + "instrument": "agent", + "callCount": 1 + } + ], + "routes": [ + { + "id": 1, + "name": "route", + "numResponses": 1, + "method": "GET", + "url": "/", + "instrument": "route" + } + ], + "commands": [ + { + "id": "c2", + "hookId": "h1", + "instrument": "command", + "message": "test", + "name": "session", + "sessionInfo": { + "id": "test", + "isGlobalSession": false, + "status": "created" + }, + "state": "passed", + "testId": "r3", + "type": "parent" + }, + { + "id": "c1", + "hookId": "r3", + "instrument": "command", + "message": "http://localhost:3000", + "name": "visit", + "state": "passed", + "testId": "r3", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z" + } + ] + }, + { + "id": "r4", + "title": "failed", + "state": "failed", + "err": { + "name": "CommandError", + "message": "failed to visit", + "stack": "failed to visit\n\ncould not visit http: //localhost:3000" + }, + "hooks": [ + { + "title": "\"before each\" hook", + "hookName": "before each", + "hookId": "h1", + "pending": false, + "body": "() => {\\n cy.session('test', () => {});\\n }", + "type": "hook", + "currentRetry": 0, + "retries": -1 + } + ], + "agents": [ + { + "id": 1, + "functionName": "get", + "name": "spy", + "alias": "getAlias", + "instrument": "agent", + "callCount": 1 + } + ], + "routes": [ + { + "id": 1, + "name": "route", + "numResponses": 1, + "method": "GET", + "url": "/", + "instrument": "route" + } + ], + "commands": [ + { + "id": "c2", + "hookId": "h1", + "instrument": "command", + "message": "test", + "name": "session", + "sessionInfo": { + "id": "test", + "isGlobalSession": false, + "status": "created" + }, + "state": "passed", + "testId": "r3", + "type": "parent" + }, + { + "id": "c1", + "hookId": "r3", + "instrument": "command", + "message": "http://localhost:3000", + "name": "visit", + "state": "passed", + "testId": "r3", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z" + } + ] + }, + { + "id": "r5", + "title": "passed after retry", + "state": "passed", + "retries": 1, + "currentRetry": 1, + "hooks": [ + { + "title": "\"before each\" hook", + "hookName": "before each", + "hookId": "h1", + "pending": false, + "body": "() => {\\n cy.session('test', () => {});\\n }", + "type": "hook", + "currentRetry": 0, + "retries": -1 + } + ], + "agents": [ + { + "id": 1, + "functionName": "get", + "name": "spy", + "alias": "getAlias", + "instrument": "agent", + "callCount": 1 + } + ], + "routes": [ + { + "id": 1, + "name": "route", + "numResponses": 1, + "method": "GET", + "url": "/", + "instrument": "route" + } + ], + "commands": [ + { + "id": "c1", + "hookId": "h1", + "instrument": "command", + "message": "test", + "name": "session", + "sessionInfo": { + "id": "test", + "isGlobalSession": false, + "status": "created" + }, + "state": "passed", + "testId": "r5", + "type": "parent" + }, + { + "id": "c2", + "hookId": "r5", + "instrument": "command", + "message": "http://localhost:3000", + "name": "visit", + "state": "passed", + "testId": "r5", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z" + } + ], + "prevAttempts": [ + { + "hookId": "r88", + "id": "c1", + "instrument": "command", + "message": "#id", + "name": "get", + "state": "failed", + "testId": "r88", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z", + "hooks": [ + { + "title": "\"before each\" hook", + "hookName": "before each", + "hookId": "h1", + "pending": false, + "body": "() => {\\n cy.session('test', () => {});\\n }", + "type": "hook", + "currentRetry": 0, + "retries": -1 + } + ], + "agents": [ + { + "id": 1, + "functionName": "get", + "name": "spy", + "alias": "getAlias", + "instrument": "agent", + "callCount": 1 + } + ], + "routes": [ + { + "id": 1, + "name": "route", + "numResponses": 1, + "method": "GET", + "url": "/", + "instrument": "route" + } + ], + "commands": [ + { + "id": "c2", + "hookId": "h1", + "instrument": "command", + "message": "test", + "name": "session", + "sessionInfo": { + "id": "test", + "isGlobalSession": false, + "status": "created" + }, + "state": "passed", + "testId": "r3", + "type": "parent" + }, + { + "hookId": "r5", + "id": "c3", + "instrument": "command", + "message": "#does_not_exist", + "name": "get", + "state": "failed", + "testId": "r5", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z", + "err": { + "name": "CommandError", + "message": "failed to get", + "stack": "failed to get element" + } + } + ] + } + ] + }, + { + "id": "r6", + "title": "failed with retries", + "state": "failed", + "retries": 1, + "currentRetry": 1, + "hooks": [ + { + "title": "\"before each\" hook", + "hookName": "before each", + "hookId": "h1", + "pending": false, + "body": "() => {\\n cy.session('test', () => {});\\n }", + "type": "hook", + "currentRetry": 0, + "retries": -1 + } + ], + "agents": [ + { + "id": 1, + "functionName": "get", + "name": "spy", + "alias": "getAlias", + "instrument": "agent", + "callCount": 1 + } + ], + "routes": [ + { + "id": 1, + "name": "route", + "numResponses": 1, + "method": "GET", + "url": "/", + "instrument": "route" + } + ], + "commands": [ + { + "id": "c2", + "hookId": "h1", + "instrument": "command", + "message": "test", + "name": "session", + "sessionInfo": { + "id": "test", + "isGlobalSession": false, + "status": "created" + }, + "state": "passed", + "testId": "r3", + "type": "parent" + }, + { + "id": "c1", + "hookId": "r6", + "instrument": "command", + "message": "http://localhost:3000", + "name": "visit", + "state": "passed", + "testId": "r6", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z" + }, + { + "hookId": "r6", + "id": "c1", + "instrument": "command", + "message": "#does_not_exist", + "name": "get", + "state": "failed", + "testId": "r6", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z", + "err": { + "name": "CommandError", + "message": "failed to get", + "stack": "failed to get element" + } + } + ], + "prevAttempts": [ + { + "hookId": "r6", + "id": "c1", + "instrument": "command", + "message": "#does_not_exist", + "name": "get", + "state": "failed", + "testId": "r6", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z", + "hooks": [ + { + "title": "\"before each\" hook", + "hookName": "before each", + "hookId": "h1", + "pending": false, + "body": "() => {\\n cy.session('test', () => {});\\n }", + "type": "hook", + "currentRetry": 0, + "retries": -1 + } + ], + "agents": [ + { + "id": 1, + "functionName": "get", + "name": "spy", + "alias": "getAlias", + "instrument": "agent", + "callCount": 1 + } + ], + "routes": [ + { + "id": 1, + "name": "route", + "numResponses": 1, + "method": "GET", + "url": "/", + "instrument": "route" + } + ], + "commands": [ + { + "id": "c1", + "hookId": "h1", + "instrument": "command", + "message": "test", + "name": "session", + "sessionInfo": { + "id": "test", + "isGlobalSession": false, + "status": "created" + }, + "state": "passed", + "testId": "r6", + "type": "parent" + }, + { + "id": "c2", + "hookId": "r6", + "instrument": "command", + "message": "http://localhost:3000", + "name": "visit", + "state": "passed", + "testId": "r6", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z" + }, + { + "hookId": "r6", + "id": "c3", + "instrument": "command", + "message": "#does_not_exist", + "name": "get", + "state": "failed", + "testId": "r6", + "timeout": 4000, + "type": "parent", + "wallClockStartedAt": "2020-01-01T00:00:00.000Z", + "err": { + "name": "CommandError", + "message": "failed to get", + "stack": "failed to get element" + } + } + ] + } + ], + "err": { + "name": "CommandError", + "message": "failed to get", + "stack": "failed to get element" + } + } + ] + } + ] +} diff --git a/packages/reporter/src/attempts/attempt-model.ts b/packages/reporter/src/attempts/attempt-model.ts index 59716cb78084..5c15dbfdecca 100644 --- a/packages/reporter/src/attempts/attempt-model.ts +++ b/packages/reporter/src/attempts/attempt-model.ts @@ -194,9 +194,23 @@ export default class Attempt { } } - @action finish (props: UpdatableTestProps) { + @action finish (props: UpdatableTestProps, isInteractive: boolean) { this.update(props) this.isActive = false + + // if the test is not open and we aren't in interactive mode, clear out the attempt details + if (!this.test.isOpen && !isInteractive) { + this._clear() + } + } + + _clear () { + this.commands = [] + this.routes = [] + this.agents = [] + this.hooks = [] + this._logs = {} + this.sessions = {} } _addAgent (props: AgentProps) { diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index ddd3efe78f33..c808e71100a4 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -95,8 +95,8 @@ const events: Events = { runnablesStore.runnableStarted(runnable) })) - runner.on('test:after:run', action('test:after:run', (runnable: TestProps) => { - runnablesStore.runnableFinished(runnable) + runner.on('test:after:run', action('test:after:run', (runnable: TestProps, isInteractive: boolean) => { + runnablesStore.runnableFinished(runnable, isInteractive) if (runnable.final && !appState.studioActive) { statsStore.incrementCount(runnable.state!) } diff --git a/packages/reporter/src/runnables/runnables-store.ts b/packages/reporter/src/runnables/runnables-store.ts index d20c5a81d008..ba8e955259b1 100644 --- a/packages/reporter/src/runnables/runnables-store.ts +++ b/packages/reporter/src/runnables/runnables-store.ts @@ -161,9 +161,9 @@ export class RunnablesStore { }) } - runnableFinished (props: TestProps) { + runnableFinished (props: TestProps, isInteractive: boolean) { this._withTest(props.id, (test) => { - test.finish(props) + test.finish(props, isInteractive) }) } diff --git a/packages/reporter/src/test/test-model.ts b/packages/reporter/src/test/test-model.ts index 61682825e433..f5eac05a97df 100644 --- a/packages/reporter/src/test/test-model.ts +++ b/packages/reporter/src/test/test-model.ts @@ -186,11 +186,11 @@ export default class Test extends Runnable { } } - @action finish (props: UpdatableTestProps) { + @action finish (props: UpdatableTestProps, isInteractive: boolean) { this._isFinished = !(props.retries && props.currentRetry) || props.currentRetry >= props.retries this._withAttempt(props.currentRetry || 0, (attempt: Attempt) => { - attempt.finish(props) + attempt.finish(props, isInteractive) }) }