internal: Redesign single test view when studio is open (#32008)

* update yarn.lock

* index on mabel/issue-31677-reporter-redesign: 5e2503f339 Merge branch 'mabel/issue-31677-reporter-redesign' of https://github.com/cypress-io/cypress into mabel/issue-31677-reporter-redesign

* index on mabel/issue-31677-reporter-redesign: 5e2503f339 Merge branch 'mabel/issue-31677-reporter-redesign' of https://github.com/cypress-io/cypress into mabel/issue-31677-reporter-redesign

* index on mabel/issue-31677-reporter-redesign: 5e2503f339 Merge branch 'mabel/issue-31677-reporter-redesign' of https://github.com/cypress-io/cypress into mabel/issue-31677-reporter-redesign

* handle open in ide and new test button shadows/padding/alignment

* link issue to TODO

* only add pointer-events:none to tests and not suites

* fix failing tests

* Update cache-version.txt

* fix failing test

* fix clear sessions width

* yarnlock update

* remove unused style

* remove unused style

* add caching when calculating children states in the suite-model

* Revert "add caching when calculating children states in the suite-model"

This reverts commit 3b59a94282.

* Remove * css style for reporter box-sizing - I don't see this impacting css styles at all

* have css only target languages we support showing in Cypress App

* Remove normalize.scss

* Remove more global css resets to improve rendering performance

* remove running state

* memoize components in runnable-and-suite

* fix failing test

* bump cache

* skip failing tests related to active states

* add clearSuiteId function

* misc: begin work on reporter redesign

* remove info icon on failing tests

* Add new queued icon to tests

* bump react-icon

* add some styles for the header

* add some styles and icons to describe blocks

* display chevron down on describe hover

* add css for red-400

* only display collapsible describes if there are tests in the suite

* add new test on describe hover

* add describe focus styles

* add describe focus styles scss

* fix add commands to test wand placement

* update stats icon with describe and test hover and focus

* update test status icons

* handles some of the test body styles and states

* add ellipsis to runnable title and flex shrink to icons

* fix command row stylings

* fix session alignment

* fix collapsible indicator styles

* handle attempt styling

* fix failing tests

* add back command status borders

* fix suites.cy.ts tests and make some styling fixes

* fix styles for New test button on focused/hovered suites

* fix header test

* attempt spacing fixes

* fix shortcuts test

* add open in ide on header hover

* make some styling fixes to errors

* make error styling changes

* update control icons and styles

* fix dotted line for suites

* add test dots

* fix logic for displaying test dots

* use stop circle icon

* refactor runnable and suite header icon

* only use test children to determine current suite state to display the suite icons

* fix suites test

* fix suite and test icon alignments

* clean up some comments and unused variables

* fix failing tests

* fix failing studio tests

* fix failing tests

* fix meta test

* fix suite_model test

* add more tests for suite-model

* fix more tests

* fix failing test

* fix padding for hook headers

* handle font weight, describe aligment and status border widths

* fix rounded corners on hover of commands

* round status border when test is opened

* handle chevron right/down when hovering when collapsible is open/closed

* add changelog entry

* yarn lock

* run on binary

* bust circle cache

* center align open in ide on command hover

* add padding to the bottom of the last suite/test

* fix attempt padding and connecting dots

* update progress bar color to gray-900

* no jumping when opening test

* top align describe/test text when the text wraps to the next line

* clean up new test button styles and add the linear gradient

* fix dotted line and describe/test padding

* round out error border and remove double red border on errors

* fix gap for stack trace

* only apply margin top to test and suite icons

* change opacity of add commands to test wand icon

* fix wand opacity test

* can we just remove this overflow: scroll?

* clean up TODOs

* fix error group line alignment

* align open IDE tooltip in hooks

* fix padding between suites

* remove purple border around describe in studio

* Add tailwind css so that styles work in e2e tests

* fix studio buttons padding

* fix stack trace padding

* disable clicking for skipped and queued up tests

* fix 1px jumping when opening test

* handle open in ide and new test button shadows/padding/alignment

* circle cache

* update yarn.lock

* index on mabel/issue-31677-reporter-redesign: 5e2503f339 Merge branch 'mabel/issue-31677-reporter-redesign' of https://github.com/cypress-io/cypress into mabel/issue-31677-reporter-redesign

* index on mabel/issue-31677-reporter-redesign: 5e2503f339 Merge branch 'mabel/issue-31677-reporter-redesign' of https://github.com/cypress-io/cypress into mabel/issue-31677-reporter-redesign

* index on mabel/issue-31677-reporter-redesign: 5e2503f339 Merge branch 'mabel/issue-31677-reporter-redesign' of https://github.com/cypress-io/cypress into mabel/issue-31677-reporter-redesign

* link issue to TODO

* only add pointer-events:none to tests and not suites

* fix failing tests

* Update cache-version.txt

* fix failing test

* fix clear sessions width

* remove unused style

* yarnlock update

* add caching when calculating children states in the suite-model

* Revert "add caching when calculating children states in the suite-model"

This reverts commit 3b59a94282.

* Remove * css style for reporter box-sizing - I don't see this impacting css styles at all

* have css only target languages we support showing in Cypress App

* Remove normalize.scss

* Remove more global css resets to improve rendering performance

* remove running state

* memoize components in runnable-and-suite

* fix failing test

* bump cache

* skip failing tests related to active states

* clean up existing studio UI

* refactor duration and openFileInIDEButtn

* update workflows file

* add single test component

* save parentTitle to use in single test mode

* add single test component

* clean up studio commands

* remove adding studio commands as a hook

* clean up hook-model from studio commands

* use new singleTest component when studio is active and on single test mode

* update reporter start to set single studio test active

* actually set single test mode

* clean up more old studio code

* fix styles for header title

* fix hooks test

* whoops re-add deleted line

* fix events.cy.ts test

* fix runnables_store test

* fix test_model test

* fix test_errors test

* fix tests test

* fix studio-cloud test

* update waitForSpecToFinish

* fix some studio tests maybe?

* fix some tests

* add back studio commands hook to tests

* add back some of the events i removed that also need to be cleaned up in the cloud

* fix some tests

* add test for back button and open in ide button in single test mode

* add component test for StudioSingleTest component

* wait for specs to finish to reduce flake when asserting on aut iframe

* add tests back

* remove studio instructions modal

* remove this branch from mac workflow

* try to fix the studio tests

* check if aut-iframe is empty

* add more checks to make sure aut is ready

* add loading tests state to StudioSingleTest

* fix single studio test

* add more conditions for the studio tests

* make a few more changes to the existing tests

* fix button styles

* add scrollbar to single test

* hide studio commands hooks

* handle scroll to view in single test mode

* show empty test state when test is errored

* update name to checkForStats

* add useScrollIntoView hook

* update waitForSpecToFinish

* remove unused props

* allow stop button to work when studio is active

* clean up StudioTest.scss

* remove runnable active and queued checks

* fix order of operations in waitForSpecToFinish

* call studio:cancel event for back button in studio test mode

* remove studio commands check

* memoize scrollIntoView callback and add it as a dependency in useEffect

* fix ts error

* check if single test studio is active when waiting for spec

* fix launchStudio new test logic

* allow shorcuts to work in studio mode

* align test studio icon at the top

* refactor spec file name into its own component

* update tests with new spec file name classname

* add checkForStats and add tests for removing url parameters

* remove spacing

* fix icon import

* fix tests.cy

* fix open file in ide test

* add style I accidentally removed

---------

Co-authored-by: Ryan Manuel <ryanm@cypress.io>
Co-authored-by: cypress-bot[bot] <+cypress-bot[bot]@users.noreply.github.com>
Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
Co-authored-by: Jennifer Shehane <shehane.jennifer@gmail.com>
This commit is contained in:
mabela416
2025-07-18 13:32:12 -04:00
committed by GitHub
parent a73f304b3b
commit 22bc78ee16
56 changed files with 1208 additions and 1264 deletions

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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')

View File

@@ -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')

View File

@@ -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=')
})
})

View File

@@ -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

View File

@@ -97,9 +97,6 @@
:get-aut-iframe="getAutIframe"
:event-manager="eventManager"
/>
<StudioControls v-if="!studioBetaAvailable && studioStore.isActive" />
<Alert
v-model="showAlert"
status="success"
@@ -131,7 +128,6 @@ import Tag from '@cypress-design/vue-tag'
import SelectorPlayground from './selector-playground/SelectorPlayground.vue'
import ExternalLink from '@packages/frontend-shared/src/gql-components/ExternalLink.vue'
import Alert from '@packages/frontend-shared/src/components/Alert.vue'
import StudioControls from './studio/StudioControls.vue'
import StudioUrlPrompt from './studio/StudioUrlPrompt.vue'
import VerticalBrowserListItems from '@packages/frontend-shared/src/gql-components/topnav/VerticalBrowserListItems.vue'
import SpecRunnerDropdown from './SpecRunnerDropdown.vue'

View File

@@ -1,14 +1,4 @@
<template>
<StudioInstructionsModal
v-if="studioStore.instructionModalIsOpen"
:open="studioStore.instructionModalIsOpen"
@close="studioStore.closeInstructionModal"
/>
<StudioSaveModal
v-if="studioStore.saveModalIsOpen"
:open="studioStore.saveModalIsOpen"
@close="studioStore.closeSaveModal"
/>
<AdjustRunnerStyleDuringScreenshot
id="main-pane"
class="flex"
@@ -143,8 +133,6 @@ import { useEventManager } from './useEventManager'
import AutomationDisconnected from './automation/AutomationDisconnected.vue'
import AutomationMissing from './automation/AutomationMissing.vue'
import { runnerConstants } from './runner-constants'
import StudioInstructionsModal from './studio/StudioInstructionsModal.vue'
import StudioSaveModal from './studio/StudioSaveModal.vue'
import { useStudioStore } from '../store/studio-store'
import StudioPanel from '../studio/StudioPanel.vue'
import { useSubscription } from '../graphql'

View File

@@ -858,6 +858,7 @@ export class EventManager {
})
const hasRunnableId = !!this.studioStore.testId || !!this.studioStore.suiteId
const studioSingleTestActive = !!this.studioStore.testId && !this.studioStore.suiteId
this.reporterBus.emit('reporter:start', {
startTime: Cypress.runner.getStartTime(),
@@ -868,6 +869,7 @@ export class EventManager {
isSpecsListOpen: runState.isSpecsListOpen,
scrollTop: runState.scrollTop,
studioActive: hasRunnableId,
studioSingleTestActive,
} as ReporterStartInfo)
}
@@ -936,7 +938,6 @@ export class EventManager {
return displayProps
}
_studioCopyToClipboard (cb) {
this.ws.emit('studio:get:commands:text', this.studioStore.logs, async (commandsText) => {
await this.studioStore.copyToClipboard(commandsText)

View File

@@ -1,139 +0,0 @@
<template>
<div
class="border-y flex border-gray-50 bg-white w-full justify-between"
data-cy="studio-toolbar"
>
<div class="flex">
<div class="flex pr-5 pl-5 items-center">
<span
v-if="studioStore.url && studioStore.isActive && !studioStore.isFailed"
class="mr-2"
><i-cy-action-record_x16 class="animate-pulse icon-dark-red-500 icon-light-red-500" /></span>
<span
v-else
class="px-2"
><i-cy-object-magic-wand-dark-mode_x16 class="fill-purple-300 stroke-purple-300" /></span>
<div class="font-semibold text-base text-gray-800">
<span>{{ t('runner.studio.studio').toUpperCase() }}</span>
<span class="ml-1"> {{ t('versions.beta').toUpperCase() }}</span>
</div>
</div>
<div class="flex items-center">
<a
class="cursor-pointer font-medium text-base text-indigo-500 hocus-link hover:underline"
@click="studioStore.openInstructionModal"
>
{{ t('runner.studio.availableCommands') }}
</a>
</div>
</div>
<div
class="flex"
data-cy="studio-toolbar-controls"
>
<div class="border rounded-md flex border-gray-100 m-1">
<Tooltip
placement="top"
>
<button
:class="`border-r ${controlsClassName}`"
:disabled="studioStore.isLoading"
data-cy="close-studio"
@click="handleClose"
>
<i-cy-delete_x16 />
</button>
<template #popper>
{{ t('runner.studio.closeStudio') }}
</template>
</Tooltip>
<Tooltip
placement="top"
>
<button
:class="`border-r ${controlsClassName}`"
:disabled="studioStore.isLoading"
data-cy="restart-studio"
@click="handleRestart"
>
<i-cy-action-restart_x16 />
</button>
<template #popper>
{{ t('runner.studio.restartStudio') }}
</template>
</Tooltip>
<Tooltip
placement="top"
>
<button
:class="controlsClassName"
:disabled="studioStore.isLoading || studioStore.isEmpty"
data-cy="copy-commands"
@click="handleCopyCommands"
@mouseleave="() => commandsCopied = false"
>
<span
v-if="commandsCopied"
><i-cy-checkmark_x16 class="icon-dark-green-400 icon-light-green-400" /></span>
<span v-else> <i-cy-general-clipboard_x16 /></span>
</button>
<template #popper>
{{ t(commandsCopied ? 'runner.studio.commandsCopied' : 'runner.studio.copyCommands') }}
</template>
</Tooltip>
</div>
<div class="flex items-center">
<button
class="rounded-md bg-indigo-500 mx-3 text-white py-2 px-3 hover:bg-indigo-400 disabled:opacity-50 disabled:pointer-events-none"
:disabled="studioStore.isLoading || studioStore.isEmpty || studioStore.isFailed"
data-cy="save"
@click="handleSaveCommands"
>
{{ t('runner.studio.saveTestButton') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from '@cy/i18n'
import { getEventManager } from '../'
import { useStudioStore } from '../../store/studio-store'
import Tooltip from '@packages/frontend-shared/src/components/Tooltip.vue'
const controlsClassName = 'border-gray-100 py-2 px-3 disabled:stroke-gray-400 disabled:pointer-events-none disabled:opacity-50'
const { t } = useI18n()
const studioStore = useStudioStore()
const eventManager = getEventManager()
const commandsCopied = ref(false)
function handleClose () {
eventManager.emit('studio:cancel', undefined)
}
function handleRestart () {
studioStore.reset()
eventManager.emit('restart', undefined)
}
function handleCopyCommands () {
eventManager.emit('studio:copy:to:clipboard', () => {
commandsCopied.value = true
})
}
function handleSaveCommands () {
studioStore.startSave()
}
</script>

View File

@@ -1,14 +0,0 @@
import StudioInstructionsModal from './StudioInstructionsModal.vue'
describe('StudioInstructionsModal', () => {
it('renders hidden by default', () => {
cy.mount(<StudioInstructionsModal open={false} />)
cy.findByTestId('studio-instructions-modal').should('not.exist')
})
it('renders open with props', () => {
cy.mount(<StudioInstructionsModal open />)
cy.findByTestId('studio-instructions-modal').should('be.visible')
cy.percySnapshot()
})
})

View File

@@ -1,67 +0,0 @@
<template>
<StandardModal
:model-value="props.open"
help-link="https://on.cypress.io/guides/references/cypress-studio"
:help-text="t('links.learnMoreButton')"
variant="bare"
data-cy="studio-instructions-modal"
@update:model-value="emit('close')"
>
<template #title>
{{ t('runner.studio.studio') }} {{ t('versions.beta') }}
</template>
<div class="max-w-2xl p-6 text-gray-900">
<p class="mb-1">
{{ t('runner.studio.studioDetailedDescription') }}
</p>
<ul class="mb-1">
<li>
<pre>.check()</pre>
</li>
<li>
<pre>.click()</pre>
</li>
<li>
<pre>.select()</pre>
</li>
<li>
<pre>.type()</pre>
</li>
<li>
<pre>.uncheck()</pre>
</li>
</ul>
<p>
{{ t('runner.studio.experimentalMessage') }}
<i18n-t
scope="global"
keypath="runner.studio.feedbackPrompt"
>
<a
class="text-indigo-500"
href="https://on.cypress.io/studio-beta"
target="_blank"
>{{ t('runner.studio.feedbackLink') }}</a>
</i18n-t>
</p>
</div>
</StandardModal>
</template>
<script lang="ts" setup>
import { useI18n } from '@cy/i18n'
import StandardModal from '@packages/frontend-shared/src/components/StandardModal.vue'
const { t } = useI18n()
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
</script>

View File

@@ -1,28 +0,0 @@
import StudioSaveModal from './StudioSaveModal.vue'
import { useStudioStore } from '../../store/studio-store'
describe('StudioSaveModal', () => {
it('renders hidden by default', () => {
cy.mount(<StudioSaveModal open={false} />)
cy.findByTestId('studio-save-modal').should('not.exist')
})
it('renders open with props', () => {
cy.mount(<StudioSaveModal open />)
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(<StudioSaveModal open />)
cy.get('#testName').focus().type('my test')
cy.findByRole('button', { name: 'Save' }).click().then(() => {
expect(saveStub).to.be.calledOnceWith('my test')
})
})
})

View File

@@ -1,75 +0,0 @@
<template>
<StandardModal
:model-value="props.open"
:no-help="true"
variant="bare"
data-cy="studio-save-modal"
@update:model-value="emit('close')"
>
<template #title>
{{ t('runner.studio.saveTest') }}
</template>
<div class="max-w-sm px-5 py-5 w-sm">
<form
class="flex items-center justify-evenly"
@submit="submit"
>
<div class="w-full">
<Input
id="testName"
v-model="testName"
class="rounded-md"
:placeholder="t('runner.studio.testName')"
type="text"
:required="true"
/>
</div>
<div class="ml-3">
<button
class="disabled:opacity-50 disabled:pointer-events-none"
type="submit"
:disabled="!testName"
>
<div class="rounded-md flex bg-indigo-500 py-1.5 px-1 align-center hover:bg-indigo-400">
<div class="pt-1 ml-2">
<i-cy-circle-check_x16 class="fill-gray-200 stroke-gray-1000" />
</div>
<div class="mx-2 font-medium text-white">
{{ t('runner.studio.saveTestButton') }}
</div>
</div>
</button>
</div>
</form>
</div>
</StandardModal>
</template>
<script lang="ts" setup>
import { useI18n } from '@cy/i18n'
import StandardModal from '@packages/frontend-shared/src/components/StandardModal.vue'
import { ref } from 'vue'
import Input from '@packages/frontend-shared/src/components/Input.vue'
import { useStudioStore } from '../../store/studio-store'
const { t } = useI18n()
const studioStore = useStudioStore()
const testName = ref('')
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
function submit (e) {
e.preventDefault()
studioStore.save(testName.value)
}
</script>

View File

@@ -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<JQuery<HTMLIFrameElement>>
return cy.get('iframe.aut-iframe').its('0.contentDocument.documentElement').should('not.be.empty').then(cy.wrap) as Cypress.Chainable<JQuery<HTMLIFrameElement>>
}
Cypress.on('uncaught:exception', (err) => !err.message.includes('ResizeObserver loop completed with undelivered notifications.'))

View File

@@ -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)
})
})
})

View File

@@ -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')
})
})
})
})
})

View File

@@ -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()

View File

@@ -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')

View File

@@ -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()
})
})
})

View File

@@ -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')
})
})
})

View File

@@ -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
}

View File

@@ -40,10 +40,9 @@ const AttemptHeader = ({ index, state }: { index: number, state: TestState }) =>
interface AttemptProps {
model: AttemptModel
scrollIntoView: Function
studioActive: boolean
}
const Attempt: React.FC<AttemptProps> = observer(({ model, scrollIntoView, studioActive }) => {
const Attempt: React.FC<AttemptProps> = observer(({ model, scrollIntoView }) => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
@@ -77,15 +76,6 @@ const Attempt: React.FC<AttemptProps> = observer(({ model, scrollIntoView, studi
{model.state === 'failed' && (
<div className='attempt-error-region'>
<TestError {...model.error} />
{studioActive && (
<div className='runnable-err-wrapper studio-err-wrapper'>
<div className='runnable-err'>
<div className='runnable-err-message'>
Studio cannot add commands to a failing test.
</div>
</div>
</div>
)}
</div>
)}
</div>
@@ -99,10 +89,9 @@ Attempt.displayName = 'Attempt'
interface AttemptsProps {
test: TestModel
scrollIntoView: Function
studioActive: boolean
}
const Attempts: React.FC<AttemptsProps> = observer(({ test, scrollIntoView, studioActive }: AttemptsProps) => {
const Attempts: React.FC<AttemptsProps> = observer(({ test, scrollIntoView }: AttemptsProps) => {
return (<ul className={cs('attempts', {
'has-multiple-attempts': test.hasMultipleAttempts,
})}>
@@ -111,7 +100,6 @@ const Attempts: React.FC<AttemptsProps> = observer(({ test, scrollIntoView, stud
<Attempt
key={attempt.id}
scrollIntoView={scrollIntoView}
studioActive={studioActive}
model={attempt}
/>
)

View File

@@ -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<CommandDetailsProps> = observer(({ model, groupId
CommandDetails.displayName = 'CommandDetails'
const CommandControls: React.FC<CommandControlsProps> = observer(({ model, commandName, events }) => {
const CommandControls: React.FC<CommandControlsProps> = 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<HTMLElement, globalThis.MouseEvent>) => {
e.preventDefault()
e.stopPropagation()
events.emit('studio:remove:command', model.number)
}, [events, model.number])
return (
<span className='command-controls'>
{model.type === 'parent' && model.isStudio && (
<i
className='far fa-times-circle studio-command-remove'
onClick={_removeStudioCommand}
/>
)}
{isSessionCommand && (
<Tag
content={model.sessionInfo?.status}
@@ -505,7 +491,7 @@ const Command: React.FC<CommandProps> = observer(({ model, aliasesWithDuplicates
return (
<>
<li className={cs('command', `command-name-${commandName}`, { 'command-is-studio': model.isStudio })}>
<li className={cs('command', `command-name-${commandName}`)}>
<div
className={cs(
'command-wrapper',
@@ -544,7 +530,7 @@ const Command: React.FC<CommandProps> = observer(({ model, aliasesWithDuplicates
</div>
)}
<CommandDetails model={model} groupId={groupId} aliasesWithDuplicates={aliasesWithDuplicates} />
<CommandControls model={model} commandName={commandName} events={events} />
<CommandControls model={model} commandName={commandName}/>
</div>
</FlashOnClick>
</div>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
import React from 'react'
import { formatDuration } from '../lib/util'
export const Duration = ({ duration }: { duration: number }) => {
return Boolean(duration) && (
<span className='duration' data-cy="spec-duration">{formatDuration(duration)}</span>
)
}

View File

@@ -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;

View File

@@ -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
);
}

View File

@@ -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'

View File

@@ -23,9 +23,10 @@ const ifThen = (condition: boolean, component: React.ReactNode) => (
interface Props {
events?: Events
appState: AppState
displayPreferencesButton?: boolean
}
const Controls: React.FC<Props> = observer(({ events = defaultEvents, appState }: Props) => {
const Controls: React.FC<Props> = 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<Props> = observer(({ events = defaultEvents, appState }
return (
<div className={cs({ 'controls-container-studio': appState.studioActive, 'controls-container': !appState.studioActive })}>
<Tooltip placement='bottom' title={<p>Open Testing Preferences</p>} className='cy-tooltip'>
<button
aria-label='Open testing preferences'
className={cs('testing-preferences-toggle', { 'open': appState.isPreferencesMenuOpen })}
onClick={action('toggle:preferences:menu', togglePreferencesMenu)}
>
{appState.isPreferencesMenuOpen ? (
<ChevronUpIcon />
) : (
<ChevronDownIcon />
)}
</button>
</Tooltip>
{displayPreferencesButton && (
<Tooltip placement='bottom' title={<p>Open Testing Preferences</p>} className='cy-tooltip'>
<button
aria-label='Open testing preferences'
className={cs('testing-preferences-toggle', { 'open': appState.isPreferencesMenuOpen })}
onClick={action('toggle:preferences:menu', togglePreferencesMenu)}
>
{appState.isPreferencesMenuOpen ? (
<ChevronUpIcon />
) : (
<ChevronDownIcon />
)}
</button>
</Tooltip>
)}
<div className='controls'>
{ifThen(appState.isPaused, (
<Tooltip placement='bottom' title={<p>Resume <span className='kbd'>C</span></p>} className='cy-tooltip'>
@@ -56,8 +59,8 @@ const Controls: React.FC<Props> = observer(({ events = defaultEvents, appState }
</Tooltip>
))}
{ifThen(appState.isRunning && !appState.isPaused, (
<Tooltip placement='bottom' title={<p>Stop Running <span className='kbd'>S</span></p>} className='cy-tooltip' visible={appState.studioActive ? false : null}>
<button aria-label='Stop' className='stop' onClick={emit('stop')} disabled={appState.studioActive}>
<Tooltip placement='bottom' title={<p>Stop Running <span className='kbd'>S</span></p>} className='cy-tooltip'>
<button aria-label='Stop' className='stop' onClick={emit('stop')}>
<IconActionStopCircle size='16' strokeColor={iconStrokeColor} />
</button>
</Tooltip>

View File

@@ -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;

View File

@@ -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) => (
</span>
)
const StudioNoCommands = () => (
<li className='command command-name-get command-state-pending command-type-parent studio-prompt'>
<span>
<div className='command-wrapper'>
<div className='command-wrapper-text'>
<span className='command-message'>
<span className='command-message-text'>
Interact with your site to add test commands. Right click to add assertions.
</span>
</span>
<span className='command-controls'>
<ArrowRightIcon />
</span>
</div>
</div>
</span>
</li>
)
export interface HookProps {
model: HookModel
showNumber: boolean
@@ -48,7 +28,7 @@ export interface HookProps {
}
const Hook: React.FC<HookProps> = observer(({ model, showNumber, scrollIntoView }: HookProps) => (
<li className={cs('hook-item', { 'hook-failed': model.failed, 'hook-studio': model.isStudio })}>
<li className={cs('hook-item', { 'hook-failed': model.failed })}>
<Collapsible
header={
<>
@@ -61,7 +41,6 @@ const Hook: React.FC<HookProps> = observer(({ model, showNumber, scrollIntoView
>
<ul className='commands-container'>
{_.map(model.commands, (command) => <Command key={command.id} model={command} aliasesWithDuplicates={model.aliasesWithDuplicates} scrollIntoView={scrollIntoView} />)}
{model.showStudioPrompt && <StudioNoCommands />}
</ul>
</Collapsible>
</li>
@@ -84,7 +63,7 @@ export interface HooksProps {
const Hooks: React.FC<HooksProps> = observer(({ state = appState, model, scrollIntoView }: HooksProps) => (
<ul className='hooks-container'>
{_.map(model.hooks, (hook) => {
if (hook.commands.length || (hook.isStudio && state.studioActive && model.state === 'passed')) {
if (hook.commands.length && hook.hookName !== 'studio commands') {
return <Hook key={hook.hookId} model={hook} scrollIntoView={scrollIntoView} showNumber={model.hookCount[hook.hookName] > 1} />
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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<SVGSVGElement> {
state: TestState
isStudio?: boolean
iconSize?: '8' | '12' | '16'
}
const StateIcon: React.FC<Props> = 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<Props> = observer((props: Props) => {
}
if (state === 'passed') {
if (isStudio) {
return (
<WandIcon {...rest} className={cs('wand-icon', rest.className)} viewBox="0 0 16 16" width="12px" height="12px" />
)
}
return (
<IconStatusPassedSimple {...rest} size={iconSize || '16'} strokeColor='jade-400' />
)

View File

@@ -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 (
<div>
<div data-cy="mounted-status">{isMounted ? 'mounted' : 'not-mounted'}</div>
<div ref={containerRef} data-cy="scroll-container" style={{ height: '100px', width: '100px' }}>
Scroll target
</div>
<button data-cy="scroll-button" onClick={scrollIntoView}>
Scroll
</button>
</div>
)
}
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(
<TestComponent
appState={mockAppState}
testState="passed"
isStudioActive={false}
/>,
)
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(
<TestComponent
appState={runningAppState}
testState="passed"
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={studioAppState}
testState="passed"
isStudioActive={true}
/>,
)
// 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(
<TestComponent
appState={disabledAppState}
testState="passed"
isStudioActive={true}
/>,
)
// 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(
<TestComponent
appState={runningAppState}
testState="processing"
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={inactiveAppState}
testState="passed"
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={runningAppState}
testState="passed"
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={inactiveAppState}
testState="passed"
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={runningAppState}
testState={state}
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={mockAppState}
testState="passed"
isStudioActive={false}
/>,
)
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(
<TestComponent
appState={runningAppState}
testState="passed"
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={runningAppState}
isStudioActive={false}
/>,
)
// 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(
<TestComponent
appState={runningAppState}
testState="passed"
/>,
)
// Wait for requestAnimationFrame to execute
cy.wait(50)
cy.get('@scrollIntoViewSpy').should('have.been.called') // Should be called because isRunning is true
})
})

View File

@@ -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<HTMLDivElement>(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,
}
}

View File

@@ -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<SingleReporterProps> = observer(({ appState = appStateD
}
}, [runnerStore.spec, runnerStore.specRunId, resetStatsOnSpecChange, previousSpecRunId])
const isStudioSingleTest = appState?.studioActive && appState.studioSingleTestActive
return (
<div className={cs(className, 'reporter', {
'studio-active': appState.studioActive,
'mounted': isMounted,
})}>
{renderReporterHeader({ appState, statsStore, runnablesStore, spec: runnerStore.spec })}
{isStudioSingleTest && runnerStore.spec ? <StudioTestHeader
spec={runnerStore.spec}
/> : renderReporterHeader({ appState, statsStore, runnablesStore, spec: runnerStore.spec })}
{appState?.isPreferencesMenuOpen ? (
<TestingPreferences appState={appState} />
) : (

View File

@@ -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<SuiteProps> = 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<RunnableProps> = observer(({ appState: appStateProps = appState, model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec }) => {
const Runnable: React.FC<RunnableProps> = observer(({ model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec }) => {
return (<>
<li
className={cs(`${model.type} runnable runnable-${model.state}`, {
'runnable-retried': model.hasRetried,
'runnable-studio': appStateProps.studioActive,
'last-test-margin-bottom': model.type === 'test' && !shouldShowConnectingDots,
})}
data-model-state={model.state}
>
{model.type === 'test'
? <Test model={model as TestModel} studioEnabled={studioEnabled} canSaveStudioLogs={canSaveStudioLogs} spec={spec} />
? <Test model={model as TestModel} studioEnabled={studioEnabled} spec={spec}/>
: <Suite model={model as SuiteModel}
studioEnabled={studioEnabled}
canSaveStudioLogs={canSaveStudioLogs}

View File

@@ -2,10 +2,10 @@ import { observer } from 'mobx-react'
import React, { ReactElement } from 'react'
import type { StatsStore } from '../header/stats-store'
import { formatDuration, getFilenameParts } from '../lib/util'
import { RunnablesStore } from './runnables-store'
import { DebugDismiss } from '../header/DebugDismiss'
import { OpenFileInIDEButton } from '../OpenFileInIDEButton'
import { Duration } from '../duration/duration'
import { SpecFileName } from '../shared/SpecFileName'
const renderRunnableHeader = (children: ReactElement) => <div className="runnable-header" data-cy="runnable-header">{children}</div>
@@ -16,8 +16,6 @@ interface RunnableHeaderProps {
}
const RunnableHeader: React.FC<RunnableHeaderProps> = 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<RunnableHeaderProps> = observer(({ spec, statsSto
)
}
const displayFileName = () => {
const specParts = getFilenameParts(spec.name)
return (
<>
<span className='spec-name'>{specParts[0]}</span><span className='spec-file-extension'>{specParts[1]}</span>
</>
)
}
const fileDetails = {
absoluteFile: spec.absolute,
column: 0,
displayFile: displayFileName(),
line: 0,
originalFile: relativeSpecPath,
relativeFile: relativeSpecPath,
}
return renderRunnableHeader(
<>
<div className='runnable-header-file-name'>
{fileDetails.displayFile || fileDetails.originalFile}{!!fileDetails.line && `:${fileDetails.line}`}{!!fileDetails.column && `:${fileDetails.column}`}
<OpenFileInIDEButton fileDetails={fileDetails} />
</div>
<SpecFileName spec={spec} />
{runnablesStore.testFilter && runnablesStore.totalTests > 0 && <DebugDismiss matched={runnablesStore.totalTests} total={runnablesStore.totalUnfilteredTests} />}
{Boolean(statsStore.duration) && (
<span className='duration' data-cy="spec-duration">{formatDuration(statsStore.duration)}</span>
)}
<Duration duration={statsStore.duration} />
</>,
)
})

View File

@@ -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

View File

@@ -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;

View File

@@ -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 = () => (
<div className='runnable-loading'>
@@ -117,9 +118,11 @@ export interface RunnablesContentProps {
error?: RunnablesErrorModel
studioEnabled: boolean
canSaveStudioLogs: boolean
appState?: AppState
statsStore: StatsStore
}
const RunnablesContent: React.FC<RunnablesContentProps> = observer(({ runnablesStore, spec, error, studioEnabled, canSaveStudioLogs }: RunnablesContentProps) => {
const RunnablesContent: React.FC<RunnablesContentProps> = observer(({ runnablesStore, spec, error, studioEnabled, canSaveStudioLogs, appState, statsStore }: RunnablesContentProps) => {
const { isReady, runnables, runnablesHistory } = runnablesStore
if (!isReady) {
@@ -140,6 +143,10 @@ const RunnablesContent: React.FC<RunnablesContentProps> = observer(({ runnablesS
const isRunning = specPath === runnablesStore.runningSpec
if (appState?.studioActive && appState?.studioSingleTestActive) {
return <StudioTest appState={appState} runnablesStore={runnablesStore} statsStore={statsStore} />
}
return (
<RunnablesList
runnables={isRunning ? runnables : runnablesHistory[specPath]}
@@ -163,7 +170,7 @@ export interface RunnablesProps {
canSaveStudioLogs: boolean
}
const Runnables: React.FC<RunnablesProps> = observer(({ appState, scroller, error, runnablesStore, spec, studioEnabled, canSaveStudioLogs }) => {
const Runnables: React.FC<RunnablesProps> = observer(({ appState, scroller, error, runnablesStore, spec, studioEnabled, canSaveStudioLogs, statsStore }) => {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -188,11 +195,13 @@ const Runnables: React.FC<RunnablesProps> = observer(({ appState, scroller, erro
return (
<div ref={containerRef} className='container'>
<RunnablesContent
appState={appState}
runnablesStore={runnablesStore}
studioEnabled={studioEnabled}
canSaveStudioLogs={canSaveStudioLogs}
spec={spec}
error={error}
statsStore={statsStore}
/>
</div>
)

View File

@@ -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;
}
}
}

View File

@@ -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 (
<>
<span className='spec-name'>{specParts[0]}</span><span className='spec-file-extension'>{specParts[1]}</span>
</>
)
}
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 <div className='spec-file-name'>
{fileDetails.displayFile || fileDetails.originalFile}{!!fileDetails.line && `:${fileDetails.line}`}{!!fileDetails.column && `:${fileDetails.column}`}
<OpenFileInIDEButton fileDetails={fileDetails} />
</div>
}

View File

@@ -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(
<StudioTest
appState={appState}
runnablesStore={runnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={testRunnablesStore}
statsStore={statsStore}
/>,
)
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(
<StudioTest
appState={appState}
runnablesStore={runnablesStore}
statsStore={statsStore}
/>,
)
cy.get('@callbackAfterUpdate').should('have.been.called')
})
it('handles missing test gracefully', () => {
const emptyRunnablesStore = { ...runnablesStore, _tests: {} } as unknown as RunnablesStore
cy.mount(
<StudioTest
appState={appState}
runnablesStore={emptyRunnablesStore}
statsStore={statsStore}
/>,
)
// Should not render anything when no test is available
cy.get('.studio-single-test-container').should('not.exist')
})
})

View File

@@ -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;
}

View File

@@ -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(
<span key={`connector-${i}`} className='studio-tooltip__breadcrumb-connector' style={{ left: `${(i * 16) + 8}px`, paddingRight: `${i * 8}px` }} />,
)
}
return connectors
}
const getParentTitlesListElements = (parentTitles: string[]) => {
return parentTitles.map((title, i) => (
<li key={`${title}-${i}`} className='studio-tooltip__breadcrumb-item' style={{ paddingLeft: `${i * 16}px` }}>
{getConnectors(i)}
<IconChevronDownSmall strokeColor='gray-300' />
<span>{title}</span>
</li>
))
}
const StatusIcon = ({ test }: { test: Test }) => {
let className = 'state-icon'
if (test.state === 'active') {
return <IconStatusRunningOutline className={className} data-cy='running-icon' size='16' fillColor='gray-700' strokeColor='indigo-400' />
}
if (test.state === 'failed') {
return <IconStatusFailedSolid className={className} data-cy='failed-icon' size='16' strokeColor='red-400' />
}
if (test.state === 'passed') {
return <IconStatusPassedSolid className={className} data-cy='passed-icon' size='16' strokeColor='jade-400' />
}
// processing state or default state
return <IconStatusQueuedOutline className={className} data-cy='queued-icon' size='16' strokeColor="gray-700" />
}
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<HTMLUListElement>(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 ? <span data-cy='studio-single-test-title' className='studio-header__test-title'>{currentTest.title}</span> : null
return (
currentTest && (
<div className='studio-single-test-container' >
<div className='studio-header__test-section'>
<StatusIcon test={currentTest} />
{parentTitles.length > 0 ? (
<Tooltip title={<ul className='studio-tooltip__breadcrumb-list' ref={tooltipRef}>
{getParentTitlesListElements(parentTitles)}
</ul>}
wrapperClassName='studio-header__test-tooltip-wrapper' className={cx(
'studio-tooltip cy-tooltip',
)}>
{testTitle}
</Tooltip>
) : testTitle}
<Duration duration={statsStore.duration} />
<Controls appState={appState} displayPreferencesButton={false} />
</div>
<div className='studio-single-test-attempts' ref={containerRef}>
<Attempts test={currentTest} scrollIntoView={scrollIntoView} />
</div>
</div>
)
)
})

View File

@@ -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(
<StudioTestHeader
spec={mockSpec}
/>,
)
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(
<StudioTestHeader
spec={mockSpec}
/>,
)
cy.get('[data-cy="studio-back-button"]').click()
cy.get('@emitSpy').should('have.been.calledWith', 'studio:cancel', undefined)
})
})

View File

@@ -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;
}
}
}

View File

@@ -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<HTMLElement>) => {
e.preventDefault()
events.emit('studio:cancel', undefined)
}, [])
return (
<>
<header className='studio-header'>
<div className='studio-header__file-section'>
<Button data-cy='studio-back-button' size='32' variant='outline-dark' className='studio-header__back-button' onClick={handleBackButton}>
<IconArrowLeft size='16' strokeColor='gray-500' />
</Button>
<SpecFileName spec={spec} />
</div>
</header>
</>
)
})

View File

@@ -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<StudioControlsProps> = 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 (
<div className='studio-controls'>
<a className='studio-cancel' onClick={_cancel}>Cancel</a>
<Tooltip
title={copySuccess ? 'Commands Copied!' : 'Copy Commands to Clipboard'}
className='cy-tooltip'
wrapperClassName='studio-copy-wrapper'
visible={!canSaveStudioLogs ? false : null}
updateCue={copySuccess}
>
<button
className={cs('studio-copy', {
'studio-copy-success': copySuccess,
})}
disabled={!canSaveStudioLogs}
onClick={_copy}
onMouseLeave={_endCopySuccess}
>
{copySuccess ? (
<CheckIcon />
) : (
<ClipboardIcon />
)}
</button>
</Tooltip>
<button className='studio-save' disabled={!canSaveStudioLogs} onClick={_save}>Save Commands</button>
</div>
)
})
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<TestProps> = 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<TestProps> = 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<TestProps> = 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 (<>
<StateIcon aria-hidden className="runnable-state-icon" state={model.state} isStudio={appStateProps.studioActive} />
<StateIcon aria-hidden className="runnable-state-icon" state={model.state} />
<span className='runnable-title'>
<span>{model.title}</span>
<span className='visually-hidden'>{model.state}</span>
@@ -176,8 +93,7 @@ const Test: React.FC<TestProps> = observer(({ model, events: eventsProps = event
hideExpander
>
<div>
<Attempts studioActive={appStateProps.studioActive} test={model} scrollIntoView={() => _scrollIntoView()} />
{appStateProps.studioActive && <StudioControls canSaveStudioLogs={canSaveStudioLogs} />}
<Attempts test={model} scrollIntoView={scrollIntoView} />
</div>
</Collapsible>
)

View File

@@ -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 {

View File

@@ -15,4 +15,5 @@ export interface ReporterStartInfo extends StatsStoreStartInfo {
autoScrollingEnabled: boolean
scrollTop: number
studioActive: boolean
studioSingleTestActive: boolean
}