diff --git a/.circleci/workflows.yml b/.circleci/workflows.yml index 0df9e08aff..ad7f8c897f 100644 --- a/.circleci/workflows.yml +++ b/.circleci/workflows.yml @@ -38,7 +38,7 @@ mainBuildFilters: &mainBuildFilters - /^release\/\d+\.\d+\.\d+$/ # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - 'update-v8-snapshot-cache-on-develop' - - 'feat/support_vite_7' + - 'mabel/issue-10425-studio-redesign' # usually we don't build Mac app - it takes a long time # but sometimes we want to really confirm we are doing the right thing @@ -62,11 +62,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters - equal: [ develop, << pipeline.git.branch >> ] # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ] - - equal: - [ - 'feat/support_vite_7', - << pipeline.git.branch >> - ] + - equal: [ 'feat/support_vite_7', << pipeline.git.branch >> ] - matches: pattern: /^release\/\d+\.\d+\.\d+$/ value: << pipeline.git.branch >> @@ -89,11 +85,7 @@ windowsWorkflowFilters: &windows-workflow-filters - equal: [ develop, << pipeline.git.branch >> ] # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ] - - equal: - [ - 'feat/support_vite_7', - << pipeline.git.branch >> - ] + - equal: [ 'feat/support_vite_7', << pipeline.git.branch >> ] - matches: pattern: /^release\/\d+\.\d+\.\d+$/ value: << pipeline.git.branch >> @@ -167,7 +159,7 @@ commands: name: Set environment variable to determine whether or not to persist artifacts command: | echo "Setting SHOULD_PERSIST_ARTIFACTS variable" - echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "feat/support_vite_7" ]]; then + echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "mabel/issue-10425-studio-redesign" ]]; then export SHOULD_PERSIST_ARTIFACTS=true fi' >> "$BASH_ENV" # You must run `setup_should_persist_artifacts` command and be using bash before running this command diff --git a/packages/app/cypress/e2e/runner/runner.ui.cy.ts b/packages/app/cypress/e2e/runner/runner.ui.cy.ts index ef80e25348..88c3cedb96 100644 --- a/packages/app/cypress/e2e/runner/runner.ui.cy.ts +++ b/packages/app/cypress/e2e/runner/runner.ui.cy.ts @@ -172,7 +172,7 @@ describe('src/cypress/runner', () => { }) cy.get('.open-in-ide-button').should('have.css', 'opacity', '0') - cy.get('.runnable-header-file-name').realHover() + cy.get('.spec-file-name').realHover() cy.get('.open-in-ide-button').first().should('have.css', 'opacity', '1').click() cy.withCtx((ctx, o) => { diff --git a/packages/app/cypress/e2e/studio/helper.ts b/packages/app/cypress/e2e/studio/helper.ts index dd4484dc39..386c049b2a 100644 --- a/packages/app/cypress/e2e/studio/helper.ts +++ b/packages/app/cypress/e2e/studio/helper.ts @@ -17,13 +17,12 @@ export function loadProjectAndRunSpec ({ projectName = 'experimental-studio' as export function launchStudio ({ specName = 'spec.cy.js', createNewTest = false, cliArgs = [''] } = {}) { loadProjectAndRunSpec({ specName, cliArgs }) - // Should not show "Studio Commands" until we've started a new Studio session. - cy.get('[data-cy="hook-name-studio commands"]').should('not.exist') + const testTitle = createNewTest ? 'New Test' : 'visits a basic html page' if (createNewTest) { cy.contains('studio functionality').as('item') } else { - cy.contains('visits a basic html page').as('item') + cy.contains(testTitle).as('item') } cy.get('@item') @@ -42,7 +41,7 @@ export function launchStudio ({ specName = 'spec.cy.js', createNewTest = false, // Studio re-executes spec before waiting for commands - wait for the spec to finish executing. cy.waitForSpecToFinish() - cy.findByTestId('hook-name-studio commands').should('exist') + cy.get('[data-cy="studio-single-test-title"]').contains(testTitle) } } @@ -59,8 +58,6 @@ export function assertClosingPanelWithoutChanges () { cy.contains('cypress/e2e/index.html') }) - cy.findByTestId('hook-name-studio commands').should('not.exist') - cy.withCtx(async (ctx) => { const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec.cy.js') diff --git a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts index b3f28b7c85..85bc148594 100644 --- a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts +++ b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts @@ -11,7 +11,7 @@ describe('Studio Cloud', () => { }) }) - it('immediately loads the studio panel', () => { + it('immediately loads the studio panel from existing test', () => { const deferred = pDefer() loadProjectAndRunSpec() @@ -29,8 +29,6 @@ describe('Studio Cloud', () => { .findByTestId('launch-studio') .click() - // regular studio is not loaded until after the test finishes - cy.findByTestId('hook-name-studio commands').should('not.exist') // cloud studio is loaded immediately cy.findByTestId('studio-panel').then(() => { // check for the loading panel from the app first @@ -42,14 +40,15 @@ describe('Studio Cloud', () => { cy.wait('@indexHtml') // Studio re-executes spec before waiting for commands - wait for the spec to finish executing. - cy.waitForSpecToFinish() + cy.waitForSpecToFinish(undefined, undefined, false) // Verify the studio panel is still open cy.findByTestId('studio-panel') - cy.findByTestId('hook-name-studio commands') + + cy.percySnapshot() }) - it('hides selector playground and studio controls when studio beta is available', () => { + it('hides selector playground and studio controls when experimentalStudio is enabled', () => { launchStudio() cy.findByTestId('studio-panel').should('be.visible') @@ -136,8 +135,6 @@ describe('Studio Cloud', () => { .findByTestId('launch-studio') .click() - // regular studio is not loaded until after the test finishes - cy.findByTestId('hook-name-studio commands').should('not.exist') // cloud studio is loaded immediately cy.findByTestId('studio-panel').then(() => { // check for the loading panel from the app first @@ -153,7 +150,6 @@ describe('Studio Cloud', () => { // Verify the studio panel is still open cy.findByTestId('studio-panel') - cy.findByTestId('hook-name-studio commands') // make sure studio is not loading cy.findByTestId('loading-studio-panel').should('not.exist') @@ -217,8 +213,6 @@ describe('Studio Cloud', () => { .findByTestId('launch-studio') .click() - // regular studio is not loaded until after the test finishes - cy.get('[data-cy="hook-name-studio commands"]').should('not.exist') // cloud studio is loaded immediately cy.findByTestId('studio-panel').then(() => { // check for the loading panel from the app first @@ -230,11 +224,10 @@ describe('Studio Cloud', () => { cy.wait('@indexHtml') // Studio re-executes spec before waiting for commands - wait for the spec to finish executing. - cy.waitForSpecToFinish() + cy.waitForSpecToFinish(undefined, undefined, false) // Verify the studio panel is still open cy.findByTestId('studio-panel') - cy.get('[data-cy="hook-name-studio commands"]') // make sure studio is not loading cy.get('[data-cy="loading-studio-panel"]').should('not.exist') diff --git a/packages/app/cypress/e2e/studio/studio.cy.ts b/packages/app/cypress/e2e/studio/studio.cy.ts index cff2ba35ae..fd66e6c407 100644 --- a/packages/app/cypress/e2e/studio/studio.cy.ts +++ b/packages/app/cypress/e2e/studio/studio.cy.ts @@ -2,6 +2,8 @@ import { launchStudio, loadProjectAndRunSpec, assertClosingPanelWithoutChanges } describe('Cypress Studio', () => { function incrementCounter (initialCount: number) { + cy.waitForSpecToFinish(undefined, undefined, false) + cy.getAutIframe().within(() => { cy.get('p').contains(`Count is ${initialCount}`) @@ -86,6 +88,8 @@ describe('studio functionality', () => { it('updates an existing test with assertions', () => { launchStudio() + cy.waitForSpecToFinish(undefined, undefined, false) + cy.getAutIframe().within(() => { cy.get('#increment').rightclick().then(() => { cy.get('.__cypress-studio-assertions-menu').shadow().contains('be enabled').realClick() @@ -148,43 +152,6 @@ describe('studio functionality', () => { }) }) - it('does not update the test when it is cancelled', () => { - launchStudio() - - incrementCounter(0) - - cy.get('.cm-line').should('contain.text', `cy.get('#increment').click();`) - - cy.get('a').contains('Cancel').click() - - // Cypress re-runs after you cancel Studio. - // Original spec should pass - cy.waitForSpecToFinish({ passCount: 1 }) - - cy.get('.command').should('have.length', 1) - - // Assert the spec was executed without any new commands. - cy.get('.command-name-visit').within(() => { - cy.contains('visit') - cy.contains('cypress/e2e/index.html') - }) - - cy.findByTestId('hook-name-studio commands').should('not.exist') - cy.findByTestId('studio-panel').should('not.exist') - - cy.withCtx(async (ctx) => { - const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec.cy.js') - - // No change, since we cancelled. - expect(spec.trim().replace(/\r/g, '')).to.eq(` -describe('studio functionality', () => { - it('visits a basic html page', () => { - cy.visit('cypress/e2e/index.html') - }) -})`.trim()) - }) - }) - it('does not update the test when studio is closed using studio header button', () => { launchStudio() @@ -437,6 +404,10 @@ describe('studio functionality', () => { it('shows assertions menu and submenu correctly', () => { launchStudio() + cy.waitForSpecToFinish(undefined, undefined, false) + + cy.contains('No commands were issued in this test.').should('not.exist') + cy.getAutIframe().within(() => { // Show menu cy.get('h1').realClick({ @@ -472,14 +443,10 @@ describe('studio functionality', () => { win.location.href = win.location.href }) - cy.waitForSpecToFinish() + cy.waitForSpecToFinish(undefined, undefined, false) // after reloading we should still be in studio mode but the commands should be removed - cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => { - cy.get('.command').should('have.length', 1) - cy.get('.studio-prompt').should('contain.text', 'Interact with your site to add test commands. Right click to add assertions.') - }) - + // so the save button should be disabled cy.findByTestId('studio-save-button').should('be.disabled') }) @@ -492,22 +459,14 @@ describe('studio functionality', () => { cy.get('button[aria-label="Rerun all tests"]').click() - cy.waitForSpecToFinish() - + cy.waitForSpecToFinish(undefined, undefined, false) // after reloading we should still be in studio mode but the commands should be removed - cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => { - cy.get('.command').should('have.length', 1) - cy.get('.studio-prompt').should('contain.text', 'Interact with your site to add test commands. Right click to add assertions.') - }) - + // the save button should be disabled since the commands were removed cy.findByTestId('studio-save-button').should('be.disabled') }) it('does not re-enter studio mode when changing pages and then coming back', () => { launchStudio() - - cy.findByTestId('hook-name-studio commands') - // go to the runs page cy.findByTestId('sidebar-link-runs-page').click() @@ -517,7 +476,6 @@ describe('studio functionality', () => { cy.waitForSpecToFinish({ passCount: 1 }) - cy.findByTestId('hook-name-studio commands').should('not.exist') cy.location().its('hash').should('not.contain', 'testId=').and('not.contain', 'studio=') }) @@ -567,7 +525,7 @@ describe('studio functionality', () => { cy.findByTestId('studio-save-button').click() - cy.waitForSpecToFinish() + cy.waitForSpecToFinish(undefined, undefined, false) // only the commands in the editor are written to the test block - ideally we should also pick up the changes from the file system // TODO: https://github.com/cypress-io/cypress-services/issues/11085 @@ -620,17 +578,32 @@ describe('studio functionality', () => { cy.findByTestId('studio-error').should('contain.text', 'Failed to save test code') }) - it('removes url parameters when selecting a different spec', () => { + it('handles clicking the open in IDE button', () => { + launchStudio() + + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx.actions.file, 'openFile') + }) + + cy.get('.open-in-ide-button').should('have.css', 'opacity', '0') + cy.get('.spec-file-name').first().realHover() + cy.get('.open-in-ide-button').first().should('have.css', 'opacity', '1').click() + cy.get('.open-in-ide-button').first().contains('Open in IDE') + + cy.percySnapshot() + }) + + it('handles back button in single test view', () => { launchStudio() cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=') - // select a different spec - cy.get('[aria-controls=reporter-inline-specs-list]').click() - cy.get('a').contains('spec-w-visit.cy.js').click() - cy.get('[aria-controls=reporter-inline-specs-list]').click() + cy.get('[data-cy="studio-back-button"]').click() cy.location().its('hash').should('not.contain', 'testId=').and('not.contain', 'studio=') + + cy.get('.runnable-title').eq(0).should('contain.text', 'studio functionality') + cy.get('.runnable-title').eq(1).should('contain.text', 'visits a basic html page') }) it('removes url parameters when going to a different page', () => { @@ -679,6 +652,8 @@ describe('studio functionality', () => { cy.findByTestId('record-button-recording').should('be.visible') + cy.waitForSpecToFinish(undefined, undefined, false) + cy.getAutIframe().within(() => { cy.get('#increment').realClick() }) @@ -688,26 +663,6 @@ describe('studio functionality', () => { cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=') }) - it('removes the studio url parameters when cancelling test changes', () => { - launchStudio() - - cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=') - - cy.get('a').contains('Cancel').click() - - cy.location().its('hash').and('not.contain', 'testId=').and('not.contain', 'studio=') - }) - - it('removes the studio url parameters when cancelling a new test', () => { - launchStudio({ specName: 'spec-w-visit.cy.js', createNewTest: true }) - - cy.location().its('hash').should('contain', 'suiteId=r2').and('contain', 'studio=') - - cy.findByTestId('studio-header-studio-button').click() - - cy.location().its('hash').and('not.contain', 'suiteId=').and('not.contain', 'studio=') - }) - it('does not remove the studio url parameters if saving fails', () => { launchStudio({ cliArgs: ['--config', 'watchForFileChanges=false'] }) @@ -738,4 +693,34 @@ describe('studio functionality', () => { cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=') }) + + it('removes the studio url parameters when closing studio existing test with the back button', () => { + launchStudio() + + cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=') + + cy.get('[data-cy="studio-back-button"]').click() + + cy.location().its('hash').and('not.contain', 'testId=').and('not.contain', 'studio=') + }) + + it('removes the studio url parameters when closing studio existing test with the studio header button', () => { + launchStudio() + + cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=') + + cy.findByTestId('studio-header-studio-button').click() + + cy.location().its('hash').and('not.contain', 'testId=').and('not.contain', 'studio=') + }) + + it('removes the studio url parameters when closing studio new test', () => { + launchStudio({ specName: 'spec-w-visit.cy.js', createNewTest: true }) + + cy.location().its('hash').should('contain', 'suiteId=r2').and('contain', 'studio=') + + cy.findByTestId('studio-header-studio-button').click() + + cy.location().its('hash').and('not.contain', 'suiteId=').and('not.contain', 'studio=') + }) }) diff --git a/packages/app/cypress/e2e/support/execute-spec.ts b/packages/app/cypress/e2e/support/execute-spec.ts index b540137007..694a85a8a4 100644 --- a/packages/app/cypress/e2e/support/execute-spec.ts +++ b/packages/app/cypress/e2e/support/execute-spec.ts @@ -17,19 +17,21 @@ 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?: ExpectedResults, timeout?: number): void + waitForSpecToFinish(expectedResults?: ExpectedResults, timeout?: number, checkStats?: boolean): void verifyE2ESelected(): void verifyCtSelected(): void } } } -export const waitForSpecToFinish = (expectedResults, timeout?: number) => { - // First ensure the test is loaded - cy.get('.passed > .num').should('exist') - cy.get('.failed > .num').should('exist') +export const waitForSpecToFinish = (expectedResults, timeout?: number, checkStats: boolean = true) => { + // when we're in studio single test mode, we don't have the stats so we can skip this + if (checkStats) { + cy.get('.passed > .num').should('exist') + cy.get('.failed > .num').should('exist') + } - // Then ensure the tests are running + // Then ensure the tests are not running cy.contains('Your tests are loading...', { timeout: timeout || 30000 }).should('not.exist') // Then ensure the tests have finished diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue index f99d37ed8d..b8dfa6fbcd 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue @@ -97,9 +97,6 @@ :get-aut-iframe="getAutIframe" :event-manager="eventManager" /> - - - - - { await this.studioStore.copyToClipboard(commandsText) diff --git a/packages/app/src/runner/studio/StudioControls.vue b/packages/app/src/runner/studio/StudioControls.vue deleted file mode 100644 index 46c3341aa8..0000000000 --- a/packages/app/src/runner/studio/StudioControls.vue +++ /dev/null @@ -1,139 +0,0 @@ - - - diff --git a/packages/app/src/runner/studio/StudioInstructionsModal.cy.tsx b/packages/app/src/runner/studio/StudioInstructionsModal.cy.tsx deleted file mode 100644 index cf9170caf1..0000000000 --- a/packages/app/src/runner/studio/StudioInstructionsModal.cy.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import StudioInstructionsModal from './StudioInstructionsModal.vue' - -describe('StudioInstructionsModal', () => { - it('renders hidden by default', () => { - cy.mount() - cy.findByTestId('studio-instructions-modal').should('not.exist') - }) - - it('renders open with props', () => { - cy.mount() - cy.findByTestId('studio-instructions-modal').should('be.visible') - cy.percySnapshot() - }) -}) diff --git a/packages/app/src/runner/studio/StudioInstructionsModal.vue b/packages/app/src/runner/studio/StudioInstructionsModal.vue deleted file mode 100644 index 1c48f8e3ef..0000000000 --- a/packages/app/src/runner/studio/StudioInstructionsModal.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - diff --git a/packages/app/src/runner/studio/StudioSaveModal.cy.tsx b/packages/app/src/runner/studio/StudioSaveModal.cy.tsx deleted file mode 100644 index 8c96ff6d8e..0000000000 --- a/packages/app/src/runner/studio/StudioSaveModal.cy.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import StudioSaveModal from './StudioSaveModal.vue' -import { useStudioStore } from '../../store/studio-store' - -describe('StudioSaveModal', () => { - it('renders hidden by default', () => { - cy.mount() - cy.findByTestId('studio-save-modal').should('not.exist') - }) - - it('renders open with props', () => { - cy.mount() - cy.findByTestId('studio-save-modal').should('be.visible') - cy.percySnapshot() - }) - - it('submits the form', () => { - const studioStore = useStudioStore() - - const saveStub = cy.stub(studioStore, 'save') - - cy.mount() - cy.get('#testName').focus().type('my test') - - cy.findByRole('button', { name: 'Save' }).click().then(() => { - expect(saveStub).to.be.calledOnceWith('my test') - }) - }) -}) diff --git a/packages/app/src/runner/studio/StudioSaveModal.vue b/packages/app/src/runner/studio/StudioSaveModal.vue deleted file mode 100644 index ea239d27f2..0000000000 --- a/packages/app/src/runner/studio/StudioSaveModal.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - diff --git a/packages/frontend-shared/cypress/support/e2e.ts b/packages/frontend-shared/cypress/support/e2e.ts index d0b2aa7aae..6b2f4498de 100644 --- a/packages/frontend-shared/cypress/support/e2e.ts +++ b/packages/frontend-shared/cypress/support/e2e.ts @@ -623,7 +623,7 @@ function validateExternalLink (subject, options: ValidateExternalLinkOptions | s } function getAutIframe () { - return cy.get('iframe.aut-iframe').its('0.contentDocument.documentElement').then(cy.wrap) as Cypress.Chainable> + return cy.get('iframe.aut-iframe').its('0.contentDocument.documentElement').should('not.be.empty').then(cy.wrap) as Cypress.Chainable> } Cypress.on('uncaught:exception', (err) => !err.message.includes('ResizeObserver loop completed with undelivered notifications.')) diff --git a/packages/reporter/cypress/e2e/commands.cy.ts b/packages/reporter/cypress/e2e/commands.cy.ts index 86f5ef0544..d95c4b1f6c 100644 --- a/packages/reporter/cypress/e2e/commands.cy.ts +++ b/packages/reporter/cypress/e2e/commands.cy.ts @@ -1079,52 +1079,4 @@ describe('commands', { viewportHeight: 1000 }, () => { cy.percySnapshot() }) }) - - context('studio commands', () => { - beforeEach(() => { - addCommand(runner, { - id: 10, - number: 7, - name: 'get', - message: '#studio-command-parent', - state: 'success', - isStudio: true, - type: 'parent', - }) - - addCommand(runner, { - id: 11, - name: 'click', - message: '#studio-command-child', - state: 'success', - isStudio: true, - type: 'child', - }) - }) - - it('studio commands have command-is-studio class', () => { - cy.contains('#studio-command-parent').closest('.command') - .should('have.class', 'command-is-studio') - - cy.contains('#studio-command-child').closest('.command') - .should('have.class', 'command-is-studio') - }) - - it('only parent studio commands display remove button', () => { - cy.contains('#studio-command-parent').closest('.command') - .find('.studio-command-remove').should('exist') - - cy.contains('#studio-command-child').closest('.command') - .find('.studio-command-remove').should('not.exist') - }) - - it('emits studio:remove:command with number when delete button is clicked', () => { - cy.spy(runner, 'emit') - - cy.contains('#studio-command-parent').closest('.command') - .find('.studio-command-remove').click() - - cy.wrap(runner.emit).should('be.calledWith', 'studio:remove:command', 7) - }) - }) }) diff --git a/packages/reporter/cypress/e2e/hooks.cy.ts b/packages/reporter/cypress/e2e/hooks.cy.ts index 62e37ff4c1..a4f03d765c 100644 --- a/packages/reporter/cypress/e2e/hooks.cy.ts +++ b/packages/reporter/cypress/e2e/hooks.cy.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events' import { RootRunnable } from '../../src/runnables/runnables-store' -import { addCommand, itHandlesFileOpening } from '../support/utils' +import { itHandlesFileOpening } from '../support/utils' describe('hooks', () => { let runner: EventEmitter @@ -161,112 +161,4 @@ describe('hooks', () => { }) }) }) - - describe('studio hook', () => { - it('is not visible when not in studio mode', () => { - cy.contains('test 1').click() - - cy.contains('studio commands').should('not.exist') - }) - - describe('with studio active', () => { - beforeEach(() => { - runner.emit('reporter:start', { studioActive: true }) - - cy.contains('test 1').click() - }) - - it('is visible with hook-studio class', () => { - cy.contains('studio commands').should('exist') - .closest('.hook-item').should('have.class', 'hook-studio') - - cy.percySnapshot() - }) - - it('is not visible if test failed', () => { - cy.contains('test 2').closest('.test') - .contains('studio commands').should('not.exist') - }) - - describe('prompt', () => { - it('displays by default and disappears once commands are added', () => { - cy.get('.hook-studio').find('.studio-prompt').should('exist').then(() => { - addCommand(runner, { - id: 1, - hookId: 'r3-studio', - number: 1, - name: 'get', - message: '#studio-command-parent', - state: 'success', - isStudio: true, - type: 'parent', - }) - - addCommand(runner, { - id: 2, - hookId: 'r3-studio', - name: 'click', - message: '#studio-command-child', - state: 'success', - isStudio: true, - type: 'child', - }) - - cy.get('.hook-studio').find('.studio-prompt').should('not.exist') - }) - }) - - it('displays when there is only a visit command and disappears once additional commands are added', () => { - addCommand(runner, { - id: 1, - hookId: 'r3-studio', - number: 1, - name: 'visit', - message: 'the://url', - state: 'success', - type: 'parent', - }) - - cy.get('.hook-studio').find('.studio-prompt').should('exist').then(() => { - addCommand(runner, { - id: 2, - hookId: 'r3-studio', - number: 2, - name: 'get', - message: '#studio-command-parent', - state: 'success', - isStudio: true, - type: 'parent', - }) - - addCommand(runner, { - id: 3, - hookId: 'r3-studio', - name: 'click', - message: '#studio-command-child', - state: 'success', - isStudio: true, - type: 'child', - }) - - cy.get('.hook-studio').find('.studio-prompt').should('not.exist') - }) - }) - - it('does not display when a failed visit command is added', () => { - addCommand(runner, { - id: 1, - hookId: 'r3-studio', - number: 1, - name: 'visit', - message: 'the://url', - state: 'failed', - type: 'parent', - }) - - cy.get('.hook-studio').find('.studio-prompt').should('not.exist') - }) - }) - }) - }) }) diff --git a/packages/reporter/cypress/e2e/runnables.cy.ts b/packages/reporter/cypress/e2e/runnables.cy.ts index 966b8cd653..3ddc97691b 100644 --- a/packages/reporter/cypress/e2e/runnables.cy.ts +++ b/packages/reporter/cypress/e2e/runnables.cy.ts @@ -137,7 +137,7 @@ describe('runnables', () => { it('does not display time if no time taken', () => { start() - cy.get('.runnable-header .runnable-header-file-name').contains('foo.js') + cy.get('.runnable-header .spec-file-name').contains('foo.js') cy.get('.runnable-header .duration').should('not.exist') }) @@ -204,7 +204,7 @@ describe('runnables', () => { }) it('contains name of spec and emits when clicked', () => { - const selector = '.runnable-header-file-name' + const selector = '.spec-file-name' cy.stub(runner, 'emit').callThrough() diff --git a/packages/reporter/cypress/e2e/spec_title.cy.ts b/packages/reporter/cypress/e2e/spec_title.cy.ts index 938c390448..ff82fe8263 100644 --- a/packages/reporter/cypress/e2e/spec_title.cy.ts +++ b/packages/reporter/cypress/e2e/spec_title.cy.ts @@ -55,7 +55,7 @@ describe('spec title', () => { }) it('displays name without path', () => { - cy.get('.runnable-header-file-name').contains('foo.js') + cy.get('.spec-file-name').contains('foo.js') cy.percySnapshot() }) @@ -63,7 +63,7 @@ describe('spec title', () => { it('displays Open in IDE button on spec name hover', () => { cy.get('.open-in-ide-button').should('have.css', 'opacity', '0') - cy.get('.runnable-header-file-name').realHover() + cy.get('.spec-file-name').realHover() cy.get('.open-in-ide-button').should('have.css', 'opacity', '1') cy.get('.open-in-ide-button').contains('Open in IDE') diff --git a/packages/reporter/cypress/e2e/test_errors.cy.ts b/packages/reporter/cypress/e2e/test_errors.cy.ts index 73770caf5d..728569b7b4 100644 --- a/packages/reporter/cypress/e2e/test_errors.cy.ts +++ b/packages/reporter/cypress/e2e/test_errors.cy.ts @@ -309,22 +309,4 @@ describe('test errors', () => { .should('have.class', 'language-text') }) }) - - describe('studio error', () => { - beforeEach(() => { - setError(runnablesWithErr) - }) - - it('is not visible by default', () => { - cy.get('.studio-err-wrapper').should('not.exist') - }) - - it('is visible when studio is active', () => { - runner.emit('reporter:start', { studioActive: true }) - - cy.get('.studio-err-wrapper').should('exist').should('be.visible') - - cy.percySnapshot() - }) - }) }) diff --git a/packages/reporter/cypress/e2e/tests.cy.ts b/packages/reporter/cypress/e2e/tests.cy.ts index 77f5cd302d..fd8df4b77f 100644 --- a/packages/reporter/cypress/e2e/tests.cy.ts +++ b/packages/reporter/cypress/e2e/tests.cy.ts @@ -229,202 +229,40 @@ describe('tests', () => { }) describe('studio controls', () => { - describe('canCopyStudioCommands is true', () => { + describe('launch studio button when studio is not active', () => { beforeEach(() => { - const runnerStore = visitAndRenderReporter(true, true) + const runnerStore = visitAndRenderReporter(true, false) - runnerStore.setCanSaveStudioLogs(true) + runnerStore.setCanSaveStudioLogs(false) + }) + it('displays studio icon with half transparency when hovering over test title', { scrollBehavior: false }, () => { cy.contains('test 1') - .scrollIntoView() - .click() - .parents('.collapsible').first() - .find('.studio-controls').as('studioControls') + .closest('.runnable-wrapper') + .realHover() + .find('.runnable-controls-studio') + .should('be.visible') + .should('have.css', 'opacity', '1') }) - it('is enabled with tooltip when there are commands', () => { - cy.get('@studioControls') - .find('.studio-copy') - .should('not.be.disabled') - .trigger('mouseover') + it('displays studio icon with no transparency and tooltip on hover', { scrollBehavior: false }, () => { + cy.contains('test 1') + .closest('.collapsible-header') + .find('.runnable-controls-studio') + .realHover() + .should('be.visible') + .should('have.css', 'opacity', '1') - cy.get('.cy-tooltip').should('have.text', 'Copy Commands to Clipboard') + cy.get('.cy-tooltip').contains('Edit in Studio') }) - it('is enabled when there are commands', () => { - cy.get('@studioControls').find('.studio-save').should('not.be.disabled') - }) - - it('is emits studio:save when clicked', () => { + it('emits studio:init:test with the suite id when studio button clicked', () => { cy.stub(runner, 'emit') - cy.get('@studioControls').find('.studio-save').click() + cy.contains('test 1').parents('.collapsible-header') + .find('.runnable-controls-studio').click() - cy.wrap(runner.emit).should('be.calledWith', 'studio:save') - }) - - it('is emits studio:copy:to:clipboard when clicked', () => { - cy.stub(runner, 'emit') - - cy.get('@studioControls').find('.studio-copy').click() - - cy.wrap(runner.emit).should('be.calledWith', 'studio:copy:to:clipboard') - }) - - it('displays success state after commands are copied', () => { - cy.stub(runner, 'emit').callsFake((event, callback) => { - if (event === 'studio:copy:to:clipboard') { - callback('') - } - }) - - cy.get('@studioControls') - .find('.studio-copy') - .click() - .should('have.class', 'studio-copy-success') - .trigger('mouseover') - - cy.get('.cy-tooltip').should('have.text', 'Commands Copied!') - }) - }) - - describe('canCopyStudioCommands is false', () => { - describe('copy button', () => { - beforeEach(() => { - const runnerStore = visitAndRenderReporter(true, true) - - runnerStore.setCanSaveStudioLogs(false) - - cy.contains('test 1') - .scrollIntoView() - .click() - .parents('.collapsible').first() - .find('.studio-controls').as('studioControls') - }) - - it('is disabled without tooltip when there are no commands', () => { - cy.get('@studioControls') - .find('.studio-copy') - .should('be.disabled') - .parent('span') - .trigger('mouseover') - - cy.get('.cy-tooltip').should('not.exist') - }) - }) - - describe('launch studio button when studio is not active', () => { - beforeEach(() => { - const runnerStore = visitAndRenderReporter(true, false) - - runnerStore.setCanSaveStudioLogs(false) - }) - - it('displays studio icon with half transparency when hovering over test title', { scrollBehavior: false }, () => { - cy.contains('test 1') - .closest('.runnable-wrapper') - .realHover() - .find('.runnable-controls-studio') - .should('be.visible') - .should('have.css', 'opacity', '1') - }) - - it('displays studio icon with no transparency and tooltip on hover', { scrollBehavior: false }, () => { - cy.contains('test 1') - .closest('.collapsible-header') - .find('.runnable-controls-studio') - .realHover() - .should('be.visible') - .should('have.css', 'opacity', '1') - - cy.get('.cy-tooltip').contains('Edit in Studio') - cy.percySnapshot('studio-icon-hover') - }) - - it('emits studio:init:test with the suite id when studio button clicked', () => { - cy.stub(runner, 'emit') - - cy.contains('test 1').parents('.collapsible-header') - .find('.runnable-controls-studio').click() - - cy.wrap(runner.emit).should('be.calledWith', 'studio:init:test', 'r3') - }) - }) - - describe('controls', () => { - it('is not visible by default', () => { - visitAndRenderReporter(false, false) - - cy.contains('test 1').click() - .parents('.collapsible').first() - .find('.studio-controls').should('not.exist') - }) - - describe('with studio active', () => { - beforeEach(() => { - const runnerStore = visitAndRenderReporter(true, true) - - runnerStore.setCanSaveStudioLogs(false) - - cy.contains('test 1') - .scrollIntoView() - .click() - .parents('.collapsible').first() - .find('.studio-controls').as('studioControls') - }) - - it('is visible with save and copy button when test passed', () => { - cy.get('@studioControls').should('be.visible') - cy.get('@studioControls').find('.studio-save').should('be.visible') - cy.get('@studioControls').find('.studio-copy').should('be.visible') - - cy.percySnapshot() - }) - - it('is visible without save and copy button if test failed', () => { - cy.contains('test 2') - .parents('.collapsible').first() - .find('.studio-controls').should('be.visible') - - cy.contains('test 2') - .parents('.collapsible').first() - .find('.studio-save').should('not.be.visible') - - cy.contains('test 2') - .parents('.collapsible').first() - .find('.studio-copy').should('not.be.visible') - }) - - it('is visible without save and copy button if test was skipped', () => { - cy.contains('nested suite 1') - .parents('.collapsible').first() - .contains('test 1').should('have.css', 'pointer-events', 'none') - .parents('.collapsible').first().scrollIntoView() - .find('.studio-controls').should('not.exist') - }) - - it('is not visible while test is running', () => { - cy.contains('nested suite 1') - .parents('.collapsible').first() - .contains('test 2').click() - .parents('.collapsible').first() - .find('.studio-controls').should('not.be.visible') - }) - - it('emits studio:cancel when cancel button clicked', () => { - cy.stub(runner, 'emit') - - cy.get('@studioControls').find('.studio-cancel').click() - - cy.wrap(runner.emit).should('be.calledWith', 'studio:cancel') - }) - - describe('save button', () => { - it('save button is disabled', () => { - cy.get('@studioControls').find('.studio-save').should('be.disabled') - }) - }) - }) + cy.wrap(runner.emit).should('be.calledWith', 'studio:init:test', 'r3') }) }) }) diff --git a/packages/reporter/cypress/e2e/unit/events.cy.ts b/packages/reporter/cypress/e2e/unit/events.cy.ts index 8deddea044..3a3cd75ca8 100644 --- a/packages/reporter/cypress/e2e/unit/events.cy.ts +++ b/packages/reporter/cypress/e2e/unit/events.cy.ts @@ -26,6 +26,7 @@ type AppStateStub = AppState & { end: SinonSpy temporarilySetAutoScrolling: SinonSpy setStudioActive: SinonSpy + setStudioSingleTestActive: SinonSpy stop: SinonSpy } @@ -38,6 +39,7 @@ const appStateStub = () => { end: sinon.spy(), temporarilySetAutoScrolling: sinon.spy(), setStudioActive: sinon.spy(), + setStudioSingleTestActive: sinon.spy(), stop: sinon.spy(), } as AppStateStub } diff --git a/packages/reporter/src/attempts/attempts.tsx b/packages/reporter/src/attempts/attempts.tsx index 5156e46d48..681869a0ae 100644 --- a/packages/reporter/src/attempts/attempts.tsx +++ b/packages/reporter/src/attempts/attempts.tsx @@ -40,10 +40,9 @@ const AttemptHeader = ({ index, state }: { index: number, state: TestState }) => interface AttemptProps { model: AttemptModel scrollIntoView: Function - studioActive: boolean } -const Attempt: React.FC = observer(({ model, scrollIntoView, studioActive }) => { +const Attempt: React.FC = observer(({ model, scrollIntoView }) => { const [isMounted, setIsMounted] = useState(false) useEffect(() => { @@ -77,15 +76,6 @@ const Attempt: React.FC = observer(({ model, scrollIntoView, studi {model.state === 'failed' && (
- {studioActive && ( -
-
-
- Studio cannot add commands to a failing test. -
-
-
- )}
)} @@ -99,10 +89,9 @@ Attempt.displayName = 'Attempt' interface AttemptsProps { test: TestModel scrollIntoView: Function - studioActive: boolean } -const Attempts: React.FC = observer(({ test, scrollIntoView, studioActive }: AttemptsProps) => { +const Attempts: React.FC = observer(({ test, scrollIntoView }: AttemptsProps) => { return (
    @@ -111,7 +100,6 @@ const Attempts: React.FC = observer(({ test, scrollIntoView, stud ) diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index 819102f60f..e148d1c191 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -2,11 +2,11 @@ import _ from 'lodash' import cs from 'classnames' import Markdown from 'markdown-it' import { observer } from 'mobx-react' -import React, { useCallback, useState, useEffect } from 'react' +import React, { useState, useEffect } from 'react' import Tooltip from '@cypress/react-tooltip' import appState from '../lib/app-state' -import events, { Events } from '../lib/events' +import events from '../lib/events' import FlashOnClick from '../lib/flash-on-click' import StateIcon from '../lib/state-icon' import Tag from '../lib/tag' @@ -314,7 +314,6 @@ interface CommandDetailsProps { interface CommandControlsProps { model: CommandModel commandName: string - events: Events } interface CommandProps { @@ -341,27 +340,14 @@ const CommandDetails: React.FC = observer(({ model, groupId CommandDetails.displayName = 'CommandDetails' -const CommandControls: React.FC = observer(({ model, commandName, events }) => { +const CommandControls: React.FC = observer(({ model, commandName }) => { const displayNumOfElements = model.state !== 'pending' && model.numElements != null && model.numElements !== 1 const isSystemEvent = model.type === 'system' && model.event const isSessionCommand = commandName === 'session' const displayNumOfChildren = !isSystemEvent && !isSessionCommand && model.hasChildren && !model.isOpen - const _removeStudioCommand = useCallback((e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - events.emit('studio:remove:command', model.number) - }, [events, model.number]) - return ( - {model.type === 'parent' && model.isStudio && ( - - )} {isSessionCommand && ( = observer(({ model, aliasesWithDuplicates return ( <> -
  • +
  • = observer(({ model, aliasesWithDuplicates
    )} - + diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 697267e794..9bc50c41ae 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -23,19 +23,6 @@ font-family: $monospace; } - .command-is-studio { - cursor: auto; - - &.command-type-parent .commands-controls .studio-command-remove { - display: block; - padding-left: 5px; - - &:hover { - color: #565554; - } - } - } - // System Command Styles .command-type-system { user-select: none; diff --git a/packages/reporter/src/duration/duration.scss b/packages/reporter/src/duration/duration.scss new file mode 100644 index 0000000000..56947aa3de --- /dev/null +++ b/packages/reporter/src/duration/duration.scss @@ -0,0 +1,10 @@ +.duration { + border: 1px solid $gray-900; + border-radius: 18px; + color: $gray-400; + font-size: 12px; + font-weight: 500; + line-height: 20px; + padding: 0 8px; + font-variant-numeric: tabular-nums; +} \ No newline at end of file diff --git a/packages/reporter/src/duration/duration.tsx b/packages/reporter/src/duration/duration.tsx new file mode 100644 index 0000000000..ac27b36459 --- /dev/null +++ b/packages/reporter/src/duration/duration.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import { formatDuration } from '../lib/util' + +export const Duration = ({ duration }: { duration: number }) => { + return Boolean(duration) && ( + {formatDuration(duration)} + ) +} diff --git a/packages/reporter/src/errors/errors.scss b/packages/reporter/src/errors/errors.scss index 54d1b318d2..65bac54587 100644 --- a/packages/reporter/src/errors/errors.scss +++ b/packages/reporter/src/errors/errors.scss @@ -88,10 +88,6 @@ $code-border-radius: 4px; } } - .studio-err-wrapper { - text-align: center; - } - .runnable-err { background-color: $err-background; border-left: 2px solid transparent; diff --git a/packages/reporter/src/header/OpenFileInIDEButton.scss b/packages/reporter/src/header/OpenFileInIDEButton.scss new file mode 100644 index 0000000000..792578d248 --- /dev/null +++ b/packages/reporter/src/header/OpenFileInIDEButton.scss @@ -0,0 +1,12 @@ +@import "../lib/mixins.scss"; + +.open-in-ide-button { + @include new-test-button; +} + +.button-hover-shadow { + @include button-hover-shadow( + $linear-gradient: linear-gradient(90deg, rgba(22, 24, 39, 0) 5.39%, $gray-1100 31.97%), + $width: 160px + ); +} \ No newline at end of file diff --git a/packages/reporter/src/OpenFileInIDEButton.tsx b/packages/reporter/src/header/OpenFileInIDEButton.tsx similarity index 95% rename from packages/reporter/src/OpenFileInIDEButton.tsx rename to packages/reporter/src/header/OpenFileInIDEButton.tsx index 8babe57898..2511ff025a 100644 --- a/packages/reporter/src/OpenFileInIDEButton.tsx +++ b/packages/reporter/src/header/OpenFileInIDEButton.tsx @@ -1,6 +1,6 @@ import Button from '@cypress-design/react-button' import React from 'react' -import events from './lib/events' +import events from '../lib/events' import { IconWindowCodeEditor } from '@cypress-design/react-icon' import { FileDetails } from '@packages/types' import cx from 'classnames' diff --git a/packages/reporter/src/header/controls.tsx b/packages/reporter/src/header/controls.tsx index e9aea6420e..b5a2dffa60 100755 --- a/packages/reporter/src/header/controls.tsx +++ b/packages/reporter/src/header/controls.tsx @@ -23,9 +23,10 @@ const ifThen = (condition: boolean, component: React.ReactNode) => ( interface Props { events?: Events appState: AppState + displayPreferencesButton?: boolean } -const Controls: React.FC = observer(({ events = defaultEvents, appState }: Props) => { +const Controls: React.FC = observer(({ events = defaultEvents, appState, displayPreferencesButton = true }: Props) => { const emit = (event: string) => () => events.emit(event) const togglePreferencesMenu = () => { appState.togglePreferencesMenu() @@ -34,19 +35,21 @@ const Controls: React.FC = observer(({ events = defaultEvents, appState } return (
    - Open Testing Preferences

    } className='cy-tooltip'> - -
    + {displayPreferencesButton && ( + Open Testing Preferences

    } className='cy-tooltip'> + +
    + )}
    {ifThen(appState.isPaused, ( Resume C

    } className='cy-tooltip'> @@ -56,8 +59,8 @@ const Controls: React.FC = observer(({ events = defaultEvents, appState }
    ))} {ifThen(appState.isRunning && !appState.isPaused, ( - Stop Running S

    } className='cy-tooltip' visible={appState.studioActive ? false : null}> -
    diff --git a/packages/reporter/src/header/header.scss b/packages/reporter/src/header/header.scss index a51d4d3a5f..b7240a50c7 100644 --- a/packages/reporter/src/header/header.scss +++ b/packages/reporter/src/header/header.scss @@ -164,54 +164,6 @@ $color-transition: color 150ms ease-out; align-items: center; flex: 1; - .runnable-header-file-name { - display: inline-flex; - align-items: center; - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - position: relative; - background: $gray-1100; - - &:after { - content: none; - } - - .spec-name { - color: $white; - font-weight: 500; - } - - .spec-file-extension { - color: $gray-300; - font-weight: 300; - } - - .button-hover-shadow { - @include button-hover-shadow( - $linear-gradient: linear-gradient(90deg, rgba(22, 24, 39, 0) 5.39%, $gray-1100 31.97%), - $width: 160px - ); - } - - .open-in-ide-button { - @include new-test-button; - } - - &:hover, - &:focus-visible { - .open-in-ide-button { - opacity: 1; - } - - .button-hover-shadow { - opacity: 1; - } - } - } - span > span > a > svg { margin-bottom: -2px; margin-right: 8px; @@ -229,19 +181,8 @@ $color-transition: color 150ms ease-out; font-weight: 500; } } - - .duration { - border: 1px solid $gray-900; - border-radius: 18px; - color: $gray-400; - font-size: 12px; - font-weight: 500; - line-height: 20px; - padding: 0 8px; - font-variant-numeric: tabular-nums; - } } - + .toggle-specs-wrapper { .toggle-specs-button { padding: 0; diff --git a/packages/reporter/src/hooks/hooks.tsx b/packages/reporter/src/hooks/hooks.tsx index ce947e9862..69228f72a3 100644 --- a/packages/reporter/src/hooks/hooks.tsx +++ b/packages/reporter/src/hooks/hooks.tsx @@ -7,8 +7,7 @@ import Command from '../commands/command' import Collapsible from '../collapsible/collapsible' import type HookModel from './hook-model' import type { HookName } from './hook-model' -import ArrowRightIcon from '@packages/frontend-shared/src/assets/icons/arrow-right_x16.svg' -import { OpenFileInIDEButton } from '../OpenFileInIDEButton' +import { OpenFileInIDEButton } from '../header/OpenFileInIDEButton' export interface HookHeaderProps { model: HookModel @@ -22,25 +21,6 @@ const HookHeader = ({ model, number }: HookHeaderProps) => ( ) -const StudioNoCommands = () => ( -
  • - -
    -
    - - - Interact with your site to add test commands. Right click to add assertions. - - - - - -
    -
    -
    -
  • -) - export interface HookProps { model: HookModel showNumber: boolean @@ -48,7 +28,7 @@ export interface HookProps { } const Hook: React.FC = observer(({ model, showNumber, scrollIntoView }: HookProps) => ( -
  • +
  • @@ -61,7 +41,6 @@ const Hook: React.FC = observer(({ model, showNumber, scrollIntoView >
      {_.map(model.commands, (command) => )} - {model.showStudioPrompt && }
  • @@ -84,7 +63,7 @@ export interface HooksProps { const Hooks: React.FC = observer(({ state = appState, model, scrollIntoView }: HooksProps) => (
      {_.map(model.hooks, (hook) => { - if (hook.commands.length || (hook.isStudio && state.studioActive && model.state === 'passed')) { + if (hook.commands.length && hook.hookName !== 'studio commands') { return 1} /> } diff --git a/packages/reporter/src/lib/app-state.ts b/packages/reporter/src/lib/app-state.ts index bdfcd9c5b8..5ee77c5b79 100644 --- a/packages/reporter/src/lib/app-state.ts +++ b/packages/reporter/src/lib/app-state.ts @@ -8,6 +8,7 @@ interface DefaultAppState { nextCommandName: string | null | undefined pinnedSnapshotId: number | string | null studioActive: boolean + studioSingleTestActive: boolean } // these are used for the `reset` method @@ -19,6 +20,7 @@ const defaults: DefaultAppState = { nextCommandName: null, pinnedSnapshotId: null, studioActive: false, + studioSingleTestActive: false, } class AppState { @@ -31,7 +33,7 @@ class AppState { nextCommandName = defaults.nextCommandName pinnedSnapshotId = defaults.pinnedSnapshotId studioActive = defaults.studioActive - + studioSingleTestActive = defaults.studioSingleTestActive isStopped = false _resetAutoScrollingEnabledTo = true; [key: string]: any @@ -47,6 +49,7 @@ class AppState { nextCommandName: observable, pinnedSnapshotId: observable, studioActive: observable, + studioSingleTestActive: observable, }) } @@ -128,6 +131,10 @@ class AppState { this.studioActive = studioActive } + setStudioSingleTestActive (studioSingleTestActive: boolean) { + this.studioSingleTestActive = studioSingleTestActive + } + reset () { _.each(defaults, (value: any, key: string) => { this[key] = value diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 1d25f9aca4..b8f58c96fc 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -86,6 +86,8 @@ const events: Events = { appState.temporarilySetAutoScrolling(startInfo.autoScrollingEnabled) runnablesStore.setInitialScrollTop(startInfo.scrollTop) appState.setStudioActive(startInfo.studioActive) + appState.setStudioSingleTestActive(startInfo.studioSingleTestActive) + if (runnablesStore.hasTests) { statsStore.start(startInfo) } diff --git a/packages/reporter/src/lib/shortcuts.ts b/packages/reporter/src/lib/shortcuts.ts index e3b38b3f91..82418bfb36 100644 --- a/packages/reporter/src/lib/shortcuts.ts +++ b/packages/reporter/src/lib/shortcuts.ts @@ -22,9 +22,9 @@ class Shortcuts { if (isAnyModifierKeyPressed || isTextLike) return switch (event.key) { - case 'r': !appState.studioActive && events.emit('restart') + case 'r': events.emit('restart') break - case 's': !appState.isPaused && !appState.studioActive && events.emit('stop') + case 's': !appState.isPaused && events.emit('stop') break case 'f': action('toggle:spec:list', () => { appState.toggleSpecList() diff --git a/packages/reporter/src/lib/state-icon.tsx b/packages/reporter/src/lib/state-icon.tsx index 66d78f3478..aedb6b7c68 100644 --- a/packages/reporter/src/lib/state-icon.tsx +++ b/packages/reporter/src/lib/state-icon.tsx @@ -1,19 +1,16 @@ -import cs from 'classnames' import { observer } from 'mobx-react' import React from 'react' import type { TestState } from '@packages/types' -import WandIcon from '@packages/frontend-shared/src/assets/icons/object-magic-wand-dark-mode_x16.svg' import { IconStatusFailedSimple, IconStatusPassedSimple, IconStatusQueuedOutline, IconStatusQueuedSimple, IconStatusRunningOutline, IconStatusRunningSimple, IconStatusSkippedOutline, IconStatusSkippedSimple } from '@cypress-design/react-icon' interface Props extends React.SVGProps { state: TestState - isStudio?: boolean iconSize?: '8' | '12' | '16' } const StateIcon: React.FC = observer((props: Props) => { - const { state, isStudio, ref, iconSize, ...rest } = props + const { state, ref, iconSize, ...rest } = props if (state === 'active') { return ( @@ -30,12 +27,6 @@ const StateIcon: React.FC = observer((props: Props) => { } if (state === 'passed') { - if (isStudio) { - return ( - - ) - } - return ( ) diff --git a/packages/reporter/src/lib/useScrollIntoView.cy.tsx b/packages/reporter/src/lib/useScrollIntoView.cy.tsx new file mode 100644 index 0000000000..d08d360eb2 --- /dev/null +++ b/packages/reporter/src/lib/useScrollIntoView.cy.tsx @@ -0,0 +1,317 @@ +import React from 'react' +import { useScrollIntoView } from './useScrollIntoView' +import { AppState } from './app-state' +import scroller from './scroller' + +// Test component to render the hook +const TestComponent = ({ appState, testState, isStudioActive }: { + appState: AppState + testState?: string + isStudioActive?: boolean +}) => { + const { containerRef, isMounted, scrollIntoView } = useScrollIntoView({ + appState, + testState, + isStudioActive, + }) + + return ( +
      +
      {isMounted ? 'mounted' : 'not-mounted'}
      +
      + Scroll target +
      + +
      + ) +} + +describe('useScrollIntoView', () => { + let mockAppState: AppState + + beforeEach(() => { + // Reset scroller to avoid container errors + scroller.__reset() + + // Create a mock container for the scroller + const mockContainer = { + clientHeight: 400, + scrollHeight: 900, + scrollTop: 0, + addEventListener: cy.stub(), + } as unknown as Element + + // Set the container on the scroller + scroller.setContainer(mockContainer) + + // Mock app state with auto-scrolling disabled by default + mockAppState = { + isRunning: false, + isPaused: false, + autoScrollingEnabled: false, // Start with false to prevent unwanted calls + scrollTop: 0, + user: null, + preferences: {}, + } as unknown as AppState + + // Spy on scroller.scrollIntoView + cy.spy(scroller, 'scrollIntoView').as('scrollIntoViewSpy') + }) + + it('sets isMounted to true after initial render', () => { + cy.mount( + , + ) + + cy.get('[data-cy="mounted-status"]').should('contain', 'mounted') + }) + + it('calls scrollIntoView when auto-scrolling is enabled and app is running', () => { + const runningAppState = { + ...mockAppState, + isRunning: true, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('have.been.called') + }) + + it('calls scrollIntoView when auto-scrolling is enabled and studio is active', () => { + const studioAppState = { + ...mockAppState, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('have.been.called') + }) + + it('does not call scrollIntoView when auto-scrolling is disabled', () => { + const disabledAppState = { + ...mockAppState, + autoScrollingEnabled: false, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('not.have.been.called') + }) + + it('does not call scrollIntoView when test state is processing', () => { + const runningAppState = { + ...mockAppState, + isRunning: true, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('not.have.been.called') + }) + + it('does not call scrollIntoView when neither running nor studio active', () => { + const inactiveAppState = { + ...mockAppState, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('not.have.been.called') + }) + + it('calls scrollIntoView when scroll button is clicked and conditions are met', () => { + const runningAppState = { + ...mockAppState, + isRunning: true, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Clear the spy calls from the initial mount + cy.get('@scrollIntoViewSpy').invoke('resetHistory') + + cy.get('[data-cy="scroll-button"]').click() + cy.get('@scrollIntoViewSpy').should('have.been.called') + }) + + it('does not call scrollIntoView when scroll button is clicked but conditions are not met', () => { + const inactiveAppState = { + ...mockAppState, + autoScrollingEnabled: false, + } as unknown as AppState + + cy.mount( + , + ) + + // Clear the spy calls from the initial mount + cy.get('@scrollIntoViewSpy').invoke('resetHistory') + + cy.get('[data-cy="scroll-button"]').click() + cy.get('@scrollIntoViewSpy').should('not.have.been.called') + }) + + it('handles different test states correctly', () => { + const runningAppState = { + ...mockAppState, + isRunning: true, + autoScrollingEnabled: true, + } as unknown as AppState + const testStates = ['passed', 'failed', 'active', 'pending'] + + testStates.forEach((state) => { + if (state === 'processing') { + // Skip processing state as it should not trigger scroll + return + } + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('have.been.called') + }) + }) + + it('provides containerRef that can be attached to DOM elements', () => { + cy.mount( + , + ) + + cy.get('[data-cy="scroll-container"]').should('be.visible') + }) + + it('calls scrollIntoView with the correct element when conditions are met', () => { + const runningAppState = { + ...mockAppState, + isRunning: true, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('have.been.called') + + // Verify it was called with the correct element + cy.get('@scrollIntoViewSpy').its('args').then((args) => { + expect(args[0][0]).to.have.attr('data-cy', 'scroll-container') + }) + }) + + it('handles undefined testState gracefully', () => { + const runningAppState = { + ...mockAppState, + isRunning: true, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('have.been.called') + }) + + it('handles undefined isStudioActive gracefully', () => { + const runningAppState = { + ...mockAppState, + isRunning: true, + autoScrollingEnabled: true, + } as unknown as AppState + + cy.mount( + , + ) + + // Wait for requestAnimationFrame to execute + cy.wait(50) + cy.get('@scrollIntoViewSpy').should('have.been.called') // Should be called because isRunning is true + }) +}) diff --git a/packages/reporter/src/lib/useScrollIntoView.ts b/packages/reporter/src/lib/useScrollIntoView.ts new file mode 100644 index 0000000000..964b797b52 --- /dev/null +++ b/packages/reporter/src/lib/useScrollIntoView.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { AppState } from './app-state' +import scroller from './scroller' + +interface UseScrollIntoViewOptions { + appState: AppState + testState?: string + isStudioActive?: boolean +} + +export const useScrollIntoView = ({ appState, testState, isStudioActive = false }: UseScrollIntoViewOptions) => { + const containerRef = useRef(null) + const [isMounted, setIsMounted] = useState(false) + + const _scrollIntoView = useCallback(() => { + if (appState.autoScrollingEnabled && (appState.isRunning || isStudioActive) && testState !== 'processing') { + window.requestAnimationFrame(() => { + // since this executes async in a RAF the ref might be null + if (containerRef.current) { + scroller.scrollIntoView(containerRef.current as HTMLElement) + } + }) + } + }, [appState.autoScrollingEnabled, appState.isRunning, isStudioActive, testState]) + + useEffect(() => { + _scrollIntoView() + if (!isMounted) { + setIsMounted(true) + } + }, [_scrollIntoView]) + + return { + containerRef, + isMounted, + scrollIntoView: _scrollIntoView, + } +} diff --git a/packages/reporter/src/main.tsx b/packages/reporter/src/main.tsx index 4758477bc1..e98c6a9264 100644 --- a/packages/reporter/src/main.tsx +++ b/packages/reporter/src/main.tsx @@ -19,6 +19,7 @@ import Header, { ReporterHeaderProps } from './header/header' import Runnables from './runnables/runnables' import TestingPreferences from './preferences/testing-preferences' import type { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store' +import { StudioTestHeader } from './studio/StudioTestHeader' function usePrevious (value) { const ref = useRef() @@ -107,12 +108,15 @@ const Reporter: React.FC = observer(({ appState = appStateD } }, [runnerStore.spec, runnerStore.specRunId, resetStatsOnSpecChange, previousSpecRunId]) + const isStudioSingleTest = appState?.studioActive && appState.studioSingleTestActive + return (
      - {renderReporterHeader({ appState, statsStore, runnablesStore, spec: runnerStore.spec })} + {isStudioSingleTest && runnerStore.spec ? : renderReporterHeader({ appState, statsStore, runnablesStore, spec: runnerStore.spec })} {appState?.isPreferencesMenuOpen ? ( ) : ( diff --git a/packages/reporter/src/runnables/runnable-and-suite.tsx b/packages/reporter/src/runnables/runnable-and-suite.tsx index 1fe63faeb0..3eed5efafd 100644 --- a/packages/reporter/src/runnables/runnable-and-suite.tsx +++ b/packages/reporter/src/runnables/runnable-and-suite.tsx @@ -3,7 +3,7 @@ import _ from 'lodash' import { observer } from 'mobx-react' import React, { MouseEvent, useCallback, useMemo } from 'react' -import appState, { AppState } from '../lib/app-state' +import appState from '../lib/app-state' import events, { Events } from '../lib/events' import Test from '../test/test' import Collapsible, { CollapsibleHeaderComponentProps } from '../collapsible/collapsible' @@ -131,7 +131,6 @@ const Suite: React.FC = observer(({ eventManager = events, model, st Suite.displayName = 'Suite' export interface RunnableProps { - appState?: AppState model: TestModel | SuiteModel studioEnabled: boolean canSaveStudioLogs: boolean @@ -143,18 +142,17 @@ export interface RunnableProps { // in order to mess with its internal state. converting it to a functional // component breaks that, so it needs to stay a Class-based component or // else the driver tests need to be refactored to support it being functional -const Runnable: React.FC = observer(({ appState: appStateProps = appState, model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec }) => { +const Runnable: React.FC = observer(({ model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec }) => { return (<>
    • {model.type === 'test' - ? + ? :
      {children}
      @@ -16,8 +16,6 @@ interface RunnableHeaderProps { } const RunnableHeader: React.FC = observer(({ spec, statsStore, runnablesStore }) => { - const relativeSpecPath = spec.relative - if (spec.relative === '__all') { if (spec.specFilter) { return renderRunnableHeader( @@ -30,35 +28,11 @@ const RunnableHeader: React.FC = observer(({ spec, statsSto ) } - const displayFileName = () => { - const specParts = getFilenameParts(spec.name) - - return ( - <> - {specParts[0]}{specParts[1]} - - ) - } - - const fileDetails = { - absoluteFile: spec.absolute, - column: 0, - displayFile: displayFileName(), - line: 0, - originalFile: relativeSpecPath, - relativeFile: relativeSpecPath, - } - return renderRunnableHeader( <> -
      - {fileDetails.displayFile || fileDetails.originalFile}{!!fileDetails.line && `:${fileDetails.line}`}{!!fileDetails.column && `:${fileDetails.column}`} - -
      + {runnablesStore.testFilter && runnablesStore.totalTests > 0 && } - {Boolean(statsStore.duration) && ( - {formatDuration(statsStore.duration)} - )} + , ) }) diff --git a/packages/reporter/src/runnables/runnables-store.ts b/packages/reporter/src/runnables/runnables-store.ts index ff139c17f3..2f8ecff183 100644 --- a/packages/reporter/src/runnables/runnables-store.ts +++ b/packages/reporter/src/runnables/runnables-store.ts @@ -125,7 +125,7 @@ export class RunnablesStore { return type === 'suite' ? this._createSuite(props as SuiteProps, level) : this._createTest(props as TestProps, level) } - _createSuite (props: SuiteProps, level: number) { + _getImmediateParentSuiteTitle (level: number): { parentTitle: string } { // Get parent suite titles by traversing up the queue const parentTitles: string[] = [] @@ -143,14 +143,27 @@ export class RunnablesStore { } // Combine parent titles with current suite title - const hierarchicalTitle = [...parentTitles, props.title].join(' > ') + // Combine parent titles + const parentTitle = [...parentTitles].join(' > ') + + return { parentTitle } + } + + _createSuite (props: SuiteProps, level: number) { + const { parentTitle } = this._getImmediateParentSuiteTitle(level) // Create new props with the hierarchical title + const hierarchicalTitle = parentTitle ? `${parentTitle} > ${props.title}` : props.title + const suiteProps = { ...props, title: hierarchicalTitle, } + if (parentTitle) { + suiteProps.parentTitle = parentTitle + } + const suite = new SuiteModel(suiteProps, level) this._runnablesQueue.push(suite) @@ -160,7 +173,17 @@ export class RunnablesStore { } _createTest (props: TestProps, level: number) { - const test = new TestModel(props, level, this) + const { parentTitle } = this._getImmediateParentSuiteTitle(level) + + const testProps = { + ...props, + } + + if (parentTitle) { + testProps.parentTitle = parentTitle + } + + const test = new TestModel(testProps, level, this) this._runnablesQueue.push(test) this._tests[test.id] = test diff --git a/packages/reporter/src/runnables/runnables.scss b/packages/reporter/src/runnables/runnables.scss index 2561874ac2..1356b1866b 100644 --- a/packages/reporter/src/runnables/runnables.scss +++ b/packages/reporter/src/runnables/runnables.scss @@ -104,11 +104,6 @@ $dotted-line-left-padding: 19px; } } - .open-studio, - .open-studio-desc { - display: inline; - } - .runnable { width: 100%; color: $gray-400; @@ -255,11 +250,6 @@ $dotted-line-left-padding: 19px; border-left: $status-border-width solid $retried; } - &.runnable-studio.runnable-passed.test > div > .runnable-wrapper, - &.runnable-studio.runnable-passed > div > .runnable-instruments { - border-left: $status-border-width solid $purple-400; - } - &.runnable-skipped > .runnable-wrapper { .runnable-title { color: $gray-600; @@ -404,17 +394,6 @@ $dotted-line-left-padding: 19px; } } - &.test.runnable-passed.runnable-studio { - .studio-controls { - display: flex; - - .studio-save, - .studio-copy { - display: flex; - } - } - } - &.test.runnable-pending { .runnable-title { color: $indigo-300; @@ -423,31 +402,11 @@ $dotted-line-left-padding: 19px; .runnable-commands-region { display: none; } - - .studio-controls { - display: flex; - } - } - - &.test.runnable-failed { - .studio-controls { - display: flex; - } } .runnable-state-icon { flex-shrink: 0; margin-top: 1px; - - &.wand-icon { - margin-top: 4px !important; - margin-left: 4px; - - .icon-light { - fill: $purple-300; - stroke: $purple-300; - } - } } } @@ -541,84 +500,6 @@ $dotted-line-left-padding: 19px; } } - .studio-controls { - display: none; - margin: 10px 16px; - align-items: center; - - button { - border-radius: 5px; - font-family: $font-sans; - font-size: 12px; - padding: 6px 20px; - - &:focus { - outline: none; - } - - &:not(.studio-copy):active { - box-shadow: inset 0 3px 5px rgba($white, 0.125); - } - } - - .studio-cancel { - color: rgba($white, 0.75); - cursor: pointer; - - &:hover { - text-decoration: underline; - } - } - - .studio-copy-wrapper { - margin-left: auto; - - .studio-copy { - background-color: $indigo-50; - color: $indigo-500; - display: none; - font-size: 16px; - padding: 6px 10px; - - &:hover, - &:focus { - background-color: $indigo-100; - } - - &.studio-copy-success { - color: $pass; - } - - &[disabled], - &[disabled]:hover, - &[disabled]:active { - color: $white; - background-color: $gray-500; - cursor: not-allowed; - } - } - } - - .studio-save { - background-color: $indigo-500; - color: $white; - display: none; - margin-left: 4px; - - &:hover { - background-color: $indigo-400; - } - - &[disabled], - &[disabled]:hover, - &[disabled]:active { - color: $white; - background-color: $gray-500; - cursor: not-allowed; - } - } - } - .runnable-loading { font-family: $font-system; diff --git a/packages/reporter/src/runnables/runnables.tsx b/packages/reporter/src/runnables/runnables.tsx index 4866d8160b..4358cf256f 100644 --- a/packages/reporter/src/runnables/runnables.tsx +++ b/packages/reporter/src/runnables/runnables.tsx @@ -15,6 +15,7 @@ import OpenFileInIDE from '../lib/open-file-in-ide' import OpenIcon from '@packages/frontend-shared/src/assets/icons/technology-code-editor_x16.svg' import StudioIcon from '@packages/frontend-shared/src/assets/icons/object-magic-wand-dark-mode_x16.svg' import WarningIcon from '@packages/frontend-shared/src/assets/icons/warning_x16.svg' +import { StudioTest } from '../studio/StudioTest' const Loading = () => (
      @@ -117,9 +118,11 @@ export interface RunnablesContentProps { error?: RunnablesErrorModel studioEnabled: boolean canSaveStudioLogs: boolean + appState?: AppState + statsStore: StatsStore } -const RunnablesContent: React.FC = observer(({ runnablesStore, spec, error, studioEnabled, canSaveStudioLogs }: RunnablesContentProps) => { +const RunnablesContent: React.FC = observer(({ runnablesStore, spec, error, studioEnabled, canSaveStudioLogs, appState, statsStore }: RunnablesContentProps) => { const { isReady, runnables, runnablesHistory } = runnablesStore if (!isReady) { @@ -140,6 +143,10 @@ const RunnablesContent: React.FC = observer(({ runnablesS const isRunning = specPath === runnablesStore.runningSpec + if (appState?.studioActive && appState?.studioSingleTestActive) { + return + } + return ( = observer(({ appState, scroller, error, runnablesStore, spec, studioEnabled, canSaveStudioLogs }) => { +const Runnables: React.FC = observer(({ appState, scroller, error, runnablesStore, spec, studioEnabled, canSaveStudioLogs, statsStore }) => { const containerRef = useRef(null) useEffect(() => { @@ -188,11 +195,13 @@ const Runnables: React.FC = observer(({ appState, scroller, erro return (
      ) diff --git a/packages/reporter/src/shared/SpecFileName.scss b/packages/reporter/src/shared/SpecFileName.scss new file mode 100644 index 0000000000..656bd54d91 --- /dev/null +++ b/packages/reporter/src/shared/SpecFileName.scss @@ -0,0 +1,36 @@ +.spec-file-name { + display: inline-flex; + align-items: center; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + background: $gray-1100; + + &:after { + content: none; + } + + .spec-name { + color: $white; + font-weight: 500; + } + + .spec-file-extension { + color: $gray-500; + font-weight: 400; + } + + &:hover, + &:focus-visible { + .open-in-ide-button { + opacity: 1; + } + + .button-hover-shadow { + opacity: 1; + } + } +} diff --git a/packages/reporter/src/shared/SpecFileName.tsx b/packages/reporter/src/shared/SpecFileName.tsx new file mode 100644 index 0000000000..232817da92 --- /dev/null +++ b/packages/reporter/src/shared/SpecFileName.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { getFilenameParts } from '../lib/util' +import { OpenFileInIDEButton } from '../header/OpenFileInIDEButton' + +const displayFileName = (spec: Cypress.Cypress['spec']) => { + const specParts = getFilenameParts(spec.name) + + return ( + <> + {specParts[0]}{specParts[1]} + + ) +} + +export const SpecFileName = ({ spec }: { spec: Cypress.Cypress['spec'] }) => { + const relativeSpecPath = spec.relative + + const fileDetails = { + absoluteFile: spec.absolute, + column: 0, + displayFile: displayFileName(spec), + line: 0, + originalFile: relativeSpecPath, + relativeFile: relativeSpecPath, + } + + return
      + {fileDetails.displayFile || fileDetails.originalFile}{!!fileDetails.line && `:${fileDetails.line}`}{!!fileDetails.column && `:${fileDetails.column}`} + +
      +} diff --git a/packages/reporter/src/studio/StudioTest.cy.tsx b/packages/reporter/src/studio/StudioTest.cy.tsx new file mode 100644 index 0000000000..8c31f17c95 --- /dev/null +++ b/packages/reporter/src/studio/StudioTest.cy.tsx @@ -0,0 +1,238 @@ +import React from 'react' +import { StudioTest } from './StudioTest' +import { AppState } from '../lib/app-state' +import { RunnablesStore } from '../runnables/runnables-store' +import { StatsStore } from '../header/stats-store' +import Test from '../test/test-model' +import scroller from '../lib/scroller' + +describe('StudioTest', () => { + let appState: AppState + let runnablesStore: RunnablesStore + let statsStore: StatsStore + let mockTest: Test + + beforeEach(() => { + // Reset scroller to avoid container errors + scroller.__reset() + + // Create a mock container for the scroller + const mockContainer = { + clientHeight: 400, + scrollHeight: 900, + scrollTop: 0, + addEventListener: cy.stub(), + } as unknown as Element + + // Set the container on the scroller + scroller.setContainer(mockContainer) + + // Mock the test with proper type casting + mockTest = { + id: 'test-1', + title: 'should display correct content', + state: 'passed', + parentTitle: 'Example Test Suite > Nested Suite', + attempts: [], + callbackAfterUpdate: cy.stub().as('callbackAfterUpdate'), + } as unknown as Test + + // Mock stores with proper type casting + appState = { + isRunning: false, + isPaused: false, + autoScrollingEnabled: true, + scrollTop: 0, + user: null, + preferences: {}, + studioActive: true, + } as unknown as AppState + + runnablesStore = { + isReady: true, + _tests: { + 'test-1': mockTest, + }, + } as unknown as RunnablesStore + + statsStore = { + duration: 1500, + } as unknown as StatsStore + }) + + it('renders component with test information', () => { + cy.mount( + , + ) + + cy.get('.studio-single-test-container').should('be.visible') + cy.get('.studio-header__test-section').should('be.visible') + cy.get('.studio-single-test-attempts').should('be.visible') + cy.get('[data-cy="studio-single-test-title"]').should('contain.text', 'should display correct content') + cy.get('[data-cy="spec-duration"]').should('contain', '00:02') + cy.percySnapshot() + }) + + it('shows correct status icon for passed test', () => { + const passedTest = { ...mockTest, state: 'passed' } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': passedTest } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="passed-icon"]').should('exist') + }) + + it('shows correct status icon for failed test', () => { + const failedTest = { ...mockTest, state: 'failed' } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': failedTest } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="failed-icon"]').should('exist') + }) + + it('shows correct status icon for running test', () => { + const runningTest = { ...mockTest, state: 'active' } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': runningTest } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="running-icon"]').should('exist') + }) + + it('shows correct status icon for queued test', () => { + const queuedTest = { ...mockTest, state: 'processing' } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': queuedTest } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="queued-icon"]').should('exist') + }) + + it('shows tooltip with parent titles', () => { + const testWithParents = { ...mockTest, parentTitle: 'Test Suite > Nested Suite' } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': testWithParents } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="studio-single-test-title"]').realHover() + cy.get('.studio-tooltip__breadcrumb-list').should('be.visible') + cy.get('.studio-tooltip__breadcrumb-item').should('have.length', 2) + cy.get('.studio-tooltip__breadcrumb-item').first().should('contain', 'Test Suite') + cy.get('.studio-tooltip__breadcrumb-item').last().should('contain', 'Nested Suite') + }) + + it('shows tooltip with very long nested titles', () => { + const testWithLongTitles = { + ...mockTest, + parentTitle: 'Very Long Suite Name That Exceeds Normal Length > Another Extremely Long Suite Name That Goes On And On > Third Level With Ridiculously Long Name > Fourth Level With Even More Text > Fifth Level With Maximum Length > Sixth Level With Overflow > Seventh Level With Truncation > Eighth Level With Wrapping > Ninth Level With Scrolling > Tenth Level With Final Test', + } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': testWithLongTitles } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="studio-single-test-title"]').realHover() + cy.get('.studio-tooltip__breadcrumb-list').should('be.visible') + cy.get('.studio-tooltip__breadcrumb-item').should('have.length', 10) + cy.percySnapshot() + }) + + it('displays test title without tooltip when no parent titles', () => { + const testWithoutParent = { ...mockTest, parentTitle: undefined } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': testWithoutParent } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="studio-single-test-title"]').should('be.visible') + cy.get('.studio-header__test-tooltip-wrapper').should('not.exist') + }) + + it('displays test title without tooltip when parent title is empty', () => { + const testWithEmptyParent = { ...mockTest, parentTitle: '' } as unknown as Test + const testRunnablesStore = { ...runnablesStore, _tests: { 'test-1': testWithEmptyParent } } as unknown as RunnablesStore + + cy.mount( + , + ) + + cy.get('[data-cy="studio-single-test-title"]').should('be.visible') + cy.get('.studio-header__test-tooltip-wrapper').should('not.exist') + }) + + it('calls callbackAfterUpdate when mounted', () => { + cy.mount( + , + ) + + cy.get('@callbackAfterUpdate').should('have.been.called') + }) + + it('handles missing test gracefully', () => { + const emptyRunnablesStore = { ...runnablesStore, _tests: {} } as unknown as RunnablesStore + + cy.mount( + , + ) + + // Should not render anything when no test is available + cy.get('.studio-single-test-container').should('not.exist') + }) +}) diff --git a/packages/reporter/src/studio/StudioTest.scss b/packages/reporter/src/studio/StudioTest.scss new file mode 100644 index 0000000000..887d6ab6e7 --- /dev/null +++ b/packages/reporter/src/studio/StudioTest.scss @@ -0,0 +1,81 @@ +.studio-single-test-container { + display: flex; + flex-direction: column; + background-color: $gray-1100; +} + +.studio-header__test-section { + display: flex; + align-items: flex-start; + border-bottom: 1px solid $gray-900; + padding: 16px; + gap: 8px; + font-size: 14px; + line-height: 20px; + flex-shrink: 0; + + .state-icon { + margin-top: 3px; + } + + svg { + flex-shrink: 0; + } + + .duration { + margin-right: 8px; + } +} + +.studio-header__test-title { + color: $white; + font-weight: 500; + flex-grow: 1; +} + +.studio-header__test-tooltip-wrapper { + display: flex; + flex-grow: 1; +} + +.studio-single-test-attempts { + overflow: auto; + flex: 1; + min-height: 0; /* Needed for Firefox */ +} + +.studio-tooltip { + padding: 12px 16px; + max-width: 350px; +} + +.studio-tooltip__breadcrumb-list { + display: flex; + flex-direction: column; + + .studio-tooltip__breadcrumb-item { + display: flex; + align-items: flex-start; + justify-content: flex-start; + gap: 8px; + color: $gray-700; + font-size: 14px; + font-weight: 400; + line-height: 20px; + padding: 4px 0; + position: relative; + word-break: break-word; + text-align: left; + + svg { + flex-shrink: 0; + margin-top: 2px; + } + } +} + +.studio-tooltip__breadcrumb-connector { + position: absolute; + height: 100%; + border-left: 1px dotted $gray-300; +} \ No newline at end of file diff --git a/packages/reporter/src/studio/StudioTest.tsx b/packages/reporter/src/studio/StudioTest.tsx new file mode 100644 index 0000000000..428e7bffe9 --- /dev/null +++ b/packages/reporter/src/studio/StudioTest.tsx @@ -0,0 +1,108 @@ +import React, { useMemo, useRef } from 'react' +import { observer } from 'mobx-react' +import { RunnablesStore } from '../runnables/runnables-store' +import { Duration } from '../duration/duration' +import Controls from '../header/controls' +import { AppState } from '../lib/app-state' +import Tooltip from '@cypress/react-tooltip' +import cx from 'classnames' +import Attempts from '../attempts/attempts' +import { useScrollIntoView } from '../lib/useScrollIntoView' +import { IconChevronDownSmall, IconStatusFailedSolid, IconStatusPassedSolid, IconStatusQueuedOutline, IconStatusRunningOutline } from '@cypress-design/react-icon' +import Test from '../test/test-model' +import { StatsStore } from '../header/stats-store' + +const getConnectors = (num: number) => { + let connectors: JSX.Element[] = [] + + for (let i = 0; i < num; i++) { + connectors.push( + , + ) + } + + return connectors +} + +const getParentTitlesListElements = (parentTitles: string[]) => { + return parentTitles.map((title, i) => ( +
    • + {getConnectors(i)} + + {title} +
    • + )) +} + +const StatusIcon = ({ test }: { test: Test }) => { + let className = 'state-icon' + + if (test.state === 'active') { + return + } + + if (test.state === 'failed') { + return + } + + if (test.state === 'passed') { + return + } + + // processing state or default state + return +} + +interface StudioTestProps { + appState: AppState + runnablesStore: RunnablesStore + statsStore: StatsStore +} + +export const StudioTest = observer(({ appState, runnablesStore, statsStore }: StudioTestProps) => { + // Single we're in single test mode, the current test is the first test in the runnablesStore._tests + const currentTest = Object.values(runnablesStore._tests)[0] + const tooltipRef = useRef(null) + + const { containerRef, isMounted, scrollIntoView } = useScrollIntoView({ + appState, + testState: currentTest?.state, + isStudioActive: appState.studioActive, + }) + + // Call callbackAfterUpdate when mounted and test changes + React.useEffect(() => { + if (isMounted && currentTest) { + currentTest.callbackAfterUpdate() + } + }, [isMounted, currentTest]) + + const parentTitles = useMemo(() => currentTest?.parentTitle ? currentTest.parentTitle.split(' > ') : [], [currentTest]) + + const testTitle = currentTest ? {currentTest.title} : null + + return ( + currentTest && ( +
      +
      + + {parentTitles.length > 0 ? ( + + {getParentTitlesListElements(parentTitles)} +
    } + wrapperClassName='studio-header__test-tooltip-wrapper' className={cx( + 'studio-tooltip cy-tooltip', + )}> + {testTitle} + + ) : testTitle} + + + +
    + +
    + + ) + ) +}) diff --git a/packages/reporter/src/studio/StudioTestHeader.cy.tsx b/packages/reporter/src/studio/StudioTestHeader.cy.tsx new file mode 100644 index 0000000000..ec250de752 --- /dev/null +++ b/packages/reporter/src/studio/StudioTestHeader.cy.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { StudioTestHeader } from './StudioTestHeader' +import events from '../lib/events' + +describe('StudioTestHeader', () => { + let mockSpec: Cypress.Cypress['spec'] + + beforeEach(() => { + // Mock the spec + mockSpec = { + name: 'cypress/e2e/example.cy.ts', + relative: 'cypress/e2e/example.cy.ts', + absolute: '/Users/test/cypress/e2e/example.cy.ts', + } as Cypress.Cypress['spec'] + + cy.spy(events, 'emit').as('emitSpy') + }) + + it('renders studio header with spec information', () => { + cy.mount( + , + ) + + cy.get('.studio-header').should('be.visible') + cy.get('.studio-header__file-section').should('be.visible') + cy.get('.spec-file-name').should('be.visible') + cy.get('.spec-file-name').should('contain.text', 'example.cy.ts') + cy.get('[data-cy="studio-back-button"]').should('be.visible') + cy.percySnapshot() + }) + + it('handles back button click', () => { + cy.mount( + , + ) + + cy.get('[data-cy="studio-back-button"]').click() + cy.get('@emitSpy').should('have.been.calledWith', 'studio:cancel', undefined) + }) +}) diff --git a/packages/reporter/src/studio/StudioTestHeader.scss b/packages/reporter/src/studio/StudioTestHeader.scss new file mode 100644 index 0000000000..b9e42290d6 --- /dev/null +++ b/packages/reporter/src/studio/StudioTestHeader.scss @@ -0,0 +1,27 @@ +.studio-header { + display: flex; + flex-direction: column; + flex-shrink: 0; + + .studio-header__file-section { + display: flex; + width: 100%; + align-items: center; + gap: 13px; + padding: 16px; + border-top: 1px solid $gray-900; + border-bottom: 1px solid $gray-900; + font-size: 14px; + line-height: 20px; + color: $gray-500; + + + .studio-header__back-button { + padding: 0; + width: 32px; + justify-content: center; + } + } +} + + diff --git a/packages/reporter/src/studio/StudioTestHeader.tsx b/packages/reporter/src/studio/StudioTestHeader.tsx new file mode 100644 index 0000000000..01e5e42847 --- /dev/null +++ b/packages/reporter/src/studio/StudioTestHeader.tsx @@ -0,0 +1,31 @@ +import React, { useCallback } from 'react' +import { observer } from 'mobx-react' +import Button from '@cypress-design/react-button' +import { IconArrowLeft } from '@cypress-design/react-icon' +import events from '../lib/events' +import { SpecFileName } from '../shared/SpecFileName' + +interface StudioHeaderProps { + spec: Cypress.Cypress['spec'] +} + +export const StudioTestHeader = observer(({ spec }: StudioHeaderProps) => { + const handleBackButton = useCallback((e: React.MouseEvent) => { + e.preventDefault() + + events.emit('studio:cancel', undefined) + }, []) + + return ( + <> +
    +
    + + +
    +
    + + ) +}) diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 57665f6835..90f17ac908 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -1,107 +1,29 @@ import { observer } from 'mobx-react' -import React, { MouseEvent, useCallback, useEffect, useRef, useState } from 'react' -import Tooltip from '@cypress/react-tooltip' +import React, { MouseEvent, useCallback } from 'react' import { IconCypressStudio } from '@cypress-design/react-icon' -import cs from 'classnames' import events, { Events } from '../lib/events' import appState, { AppState } from '../lib/app-state' import Collapsible from '../collapsible/collapsible' import TestModel from './test-model' - -import scroller, { Scroller } from '../lib/scroller' import Attempts from '../attempts/attempts' import StateIcon from '../lib/state-icon' import { LaunchStudioIcon } from '../components/LaunchStudioIcon' - -import CheckIcon from '@packages/frontend-shared/src/assets/icons/checkmark_x16.svg' -import ClipboardIcon from '@packages/frontend-shared/src/assets/icons/general-clipboard_x16.svg' - -interface StudioControlsProps { - events?: Events - canSaveStudioLogs: boolean -} - -const StudioControls: React.FC = observer(({ events: eventsProps = events, canSaveStudioLogs }) => { - const [copySuccess, setCopySuccess] = useState(false) - - const _cancel = useCallback((e: MouseEvent) => { - e.preventDefault() - - eventsProps.emit('studio:cancel') - }, [eventsProps]) - - const _save = useCallback((e: MouseEvent) => { - e.preventDefault() - - eventsProps.emit('studio:save') - }, [eventsProps]) - - const _copy = useCallback((e: MouseEvent) => { - e.preventDefault() - - eventsProps.emit('studio:copy:to:clipboard', () => { - setCopySuccess(true) - }) - }, [eventsProps]) - - const _endCopySuccess = useCallback(() => { - if (copySuccess) { - setCopySuccess(false) - } - }, [copySuccess]) - - return ( -
    - Cancel - - - - -
    - ) -}) +import { useScrollIntoView } from '../lib/useScrollIntoView' interface TestProps { events?: Events appState?: AppState - scroller?: Scroller model: TestModel studioEnabled: boolean - canSaveStudioLogs: boolean spec?: Cypress.Cypress['spec'] } -const Test: React.FC = observer(({ model, events: eventsProps = events, appState: appStateProps = appState, scroller: scrollerProps = scroller, studioEnabled, canSaveStudioLogs, spec }) => { - const containerRef = useRef(null) - const [isMounted, setIsMounted] = useState(false) - - useEffect(() => { - _scrollIntoView() - if (!isMounted) { - setIsMounted(true) - } else { - model.callbackAfterUpdate() - } +const Test: React.FC = observer(({ model, events: eventsProps = events, appState: appStateProps = appState, studioEnabled, spec }) => { + const { containerRef, isMounted, scrollIntoView } = useScrollIntoView({ + appState: appStateProps, + testState: model.state, + isStudioActive: appStateProps.studioActive, }) const _launchStudio = useCallback((e: MouseEvent) => { @@ -111,20 +33,15 @@ const Test: React.FC = observer(({ model, events: eventsProps = event eventsProps.emit('studio:init:test', model.id) }, [eventsProps, model.id]) - const _scrollIntoView = () => { - if (appStateProps.autoScrollingEnabled && (appStateProps.isRunning || appStateProps.studioActive) && model.state !== 'processing') { - window.requestAnimationFrame(() => { - // since this executes async in a RAF the ref might be null - if (containerRef.current) { - scrollerProps.scrollIntoView(containerRef.current as HTMLElement) - } - }) + React.useEffect(() => { + if (isMounted) { + model.callbackAfterUpdate() } - } + }, [isMounted, model]) const _header = () => { return (<> - + {model.title} {model.state} @@ -176,8 +93,7 @@ const Test: React.FC = observer(({ model, events: eventsProps = event hideExpander >
    - _scrollIntoView()} /> - {appStateProps.studioActive && } +
    ) diff --git a/packages/runner/src/header/header.scss b/packages/runner/src/header/header.scss index e7e5b19a27..2fc4cd0797 100644 --- a/packages/runner/src/header/header.scss +++ b/packages/runner/src/header/header.scss @@ -396,26 +396,6 @@ text-decoration: none; } } - - .studio-controls { - display: flex; - margin-left: auto; - - .button-studio { - border-left: 1px solid #e3e3e3; - font-size: 1.25em; - } - - .button-success { - color: $success; - } - - .button-studio-copy { - i { - width: 15px; - } - } - } } .url-container { diff --git a/packages/types/src/reporter.ts b/packages/types/src/reporter.ts index 5df729a945..980e573359 100644 --- a/packages/types/src/reporter.ts +++ b/packages/types/src/reporter.ts @@ -15,4 +15,5 @@ export interface ReporterStartInfo extends StatsStoreStartInfo { autoScrollingEnabled: boolean scrollTop: number studioActive: boolean + studioSingleTestActive: boolean }