mirror of
https://github.com/cypress-io/cypress.git
synced 2026-01-05 14:09:46 -06:00
internal: Redesign single test view when studio is open (#32008)
* update yarn.lock * index on mabel/issue-31677-reporter-redesign:5e2503f339Merge 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:5e2503f339Merge 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:5e2503f339Merge 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 commit3b59a94282. * 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:5e2503f339Merge 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:5e2503f339Merge 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:5e2503f339Merge 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 commit3b59a94282. * 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:
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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=')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
packages/reporter/src/duration/duration.scss
Normal file
10
packages/reporter/src/duration/duration.scss
Normal 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;
|
||||
}
|
||||
8
packages/reporter/src/duration/duration.tsx
Normal file
8
packages/reporter/src/duration/duration.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
12
packages/reporter/src/header/OpenFileInIDEButton.scss
Normal file
12
packages/reporter/src/header/OpenFileInIDEButton.scss
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' />
|
||||
)
|
||||
|
||||
317
packages/reporter/src/lib/useScrollIntoView.cy.tsx
Normal file
317
packages/reporter/src/lib/useScrollIntoView.cy.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
38
packages/reporter/src/lib/useScrollIntoView.ts
Normal file
38
packages/reporter/src/lib/useScrollIntoView.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
</>,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
36
packages/reporter/src/shared/SpecFileName.scss
Normal file
36
packages/reporter/src/shared/SpecFileName.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/reporter/src/shared/SpecFileName.tsx
Normal file
31
packages/reporter/src/shared/SpecFileName.tsx
Normal 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>
|
||||
}
|
||||
238
packages/reporter/src/studio/StudioTest.cy.tsx
Normal file
238
packages/reporter/src/studio/StudioTest.cy.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
81
packages/reporter/src/studio/StudioTest.scss
Normal file
81
packages/reporter/src/studio/StudioTest.scss
Normal 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;
|
||||
}
|
||||
108
packages/reporter/src/studio/StudioTest.tsx
Normal file
108
packages/reporter/src/studio/StudioTest.tsx
Normal 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>
|
||||
)
|
||||
)
|
||||
})
|
||||
44
packages/reporter/src/studio/StudioTestHeader.cy.tsx
Normal file
44
packages/reporter/src/studio/StudioTestHeader.cy.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
27
packages/reporter/src/studio/StudioTestHeader.scss
Normal file
27
packages/reporter/src/studio/StudioTestHeader.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
packages/reporter/src/studio/StudioTestHeader.tsx
Normal file
31
packages/reporter/src/studio/StudioTestHeader.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,4 +15,5 @@ export interface ReporterStartInfo extends StatsStoreStartInfo {
|
||||
autoScrollingEnabled: boolean
|
||||
scrollTop: number
|
||||
studioActive: boolean
|
||||
studioSingleTestActive: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user