Skip to content

Commit

Permalink
feat: Cypress Studio for Cypress 10 (#23544)
Browse files Browse the repository at this point in the history
* chore: wire up Cypress Studio  (#23413)

* wip

* wip

* wip - spike

* more wip [skip ci]

* update style

* fix ts

* move types around

* extract types

* lint

* fixing tests

* fix component test

* skip some tests

* do not error on experimentalStudio flag

* add studio controls placeholder

* fixing tests

* revert

* revert changes

* rename store

* rename method

* remove comment

* refactor

* correctly feature flag studio

* simplify code

* simplify code

* lift check into useEventManager

* correctly hide create studio prompt based on flag;

* remove superfulous css

* rename variables

* fix bugs

* wip

* unskip tests

* unskip more tests

* fix a bug in the assertion API

* fix bug in assertions [skip ci]

* wip - bugs [skip ci]

* feat: add experimentalStudio flag back (#23506)

Co-authored-by: astone123 <adams@cypress.io>

* chore: Add Studio UI to Cypress 10 (#23537)

* wip

* wip

* wip - spike

* more wip [skip ci]

* update style

* fix ts

* move types around

* extract types

* lint

* fixing tests

* fix component test

* skip some tests

* do not error on experimentalStudio flag

* add studio controls placeholder

* fixing tests

* revert

* revert changes

* rename store

* rename method

* remove comment

* refactor

* correctly feature flag studio

* chore: wip add barebones studio modals

* simplify code

* simplify code

* lift check into useEventManager

* correctly hide create studio prompt based on flag;

* remove superfulous css

* chore: style studio toolbar

* chore: misc feedback

* chore: remove studio store prop

* chore: studio URL prompt and other changes

* update component

* chore: UI styling and remove studio init modal

* chore: revert unnecessary changes

* chore: fix types

* chore: fix some tests, minor refactor (#23545)

* fix test

* fix test

* add noHelp link to StandardModal

Co-authored-by: Lachlan Miller <lachlan.miller.1990@outlook.com>

* test: studio e2e tests (#23546)

* add basic e2e test

* add some e2e tests for studio and a note on limitations

* additional spec

* add more tests, refactor helper

* fix bug in studio

* remove test code

* chore: UI feedback

* fix race condition

* update tests

* rename test

* improve types in reporter

* remove dead code

* improve tests

* merge tests into one spec

* chore: Cap instruction modal width; exit studio mode when new spec is chosen

* chore: Only render studio error when test has failed; add test for studioEnabled

* correctly check if command is studio or not

* improve specs and hopefully reduce flake

* communicate studio state from app->reporter

* receive studio save state validity from app

* fix test

* improve test coverage

* fix external link

Co-authored-by: astone123 <adams@cypress.io>
  • Loading branch information
lmiller1990 and astone123 committed Aug 29, 2022
1 parent 6a614c3 commit 72b8a65
Show file tree
Hide file tree
Showing 81 changed files with 2,461 additions and 2,744 deletions.
9 changes: 7 additions & 2 deletions cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2867,10 +2867,15 @@ declare namespace Cypress {
*/
experimentalModifyObstructiveThirdPartyCode: boolean
/**
* Generate and save commands directly to your test suite by interacting with your app as an end user would.
* Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm.
* @default false
*/
experimentalSourceRewriting: boolean
/**
* Generate and save commands directly to your test suite by interacting with your app as an end user would.
* @default false
*/
experimentalStudio: boolean
/**
* Number of times to retry a failed test.
* If a number is set, tests will retry in both runMode and openMode.
Expand Down Expand Up @@ -3073,7 +3078,7 @@ declare namespace Cypress {
}
}

interface ComponentConfigOptions<ComponentDevServerOpts = any> extends Omit<CoreConfigOptions, 'baseUrl' | 'experimentalSessionAndOrigin'> {
interface ComponentConfigOptions<ComponentDevServerOpts = any> extends Omit<CoreConfigOptions, 'baseUrl' | 'experimentalSessionAndOrigin' | 'experimentalStudio'> {
devServer: DevServerFn<ComponentDevServerOpts> | DevServerConfigOptions
devServerConfig?: ComponentDevServerOpts
/**
Expand Down
1 change: 1 addition & 0 deletions packages/app/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineConfig({
},
},
'e2e': {
experimentalStudio: true,
baseUrl: 'http://localhost:5555',
supportFile: 'cypress/e2e/support/e2eSupport.ts',
async setupNodeEvents (on, config) {
Expand Down
4 changes: 0 additions & 4 deletions packages/app/cypress/component/support/ctSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { AutIframe } from '../../../src/runner/aut-iframe'
import { EventManager } from '../../../src/runner/event-manager'
import type { Socket } from '@packages/socket/lib/browser'

class StudioRecorderMock {}

export const StubWebsocket = new Proxy<Socket>(Object.create(null), {
get: (obj, prop) => {
throw Error(`Cannot access ${String(prop)} on StubWebsocket!`)
Expand All @@ -29,7 +27,6 @@ export const createEventManager = () => {
// @ts-ignore
null, // MobX, also not needed in Vue CT tests,
null, // selectorPlaygroundModel,
StudioRecorderMock, // needs to be a valid class
StubWebsocket,
)
}
Expand All @@ -53,6 +50,5 @@ export const createTestAutIframe = (eventManager = createEventManager()) => {
// dom - imports driver which causes problems
// so just stubbing it out for now
mockDom,
eventManager.studioRecorder,
)
}
2 changes: 1 addition & 1 deletion packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ describe('Cypress In Cypress E2E', { viewportWidth: 1500, defaultCommandTimeout:
})

it('shows a compilation error with a malformed spec', { viewportHeight: 596, viewportWidth: 1000 }, () => {
const expectedAutHeight = 500 // based on explicitly setting viewport in this test to 596
const expectedAutHeight = 456 // based on explicitly setting viewport in this test to 596

cy.visitApp()

Expand Down
2 changes: 1 addition & 1 deletion packages/app/cypress/e2e/cypress-in-cypress.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ describe('Cypress in Cypress', { viewportWidth: 1500, defaultCommandTimeout: 100
cy.visitApp()
cy.contains('dom-content.spec').click()

cy.contains('http://localhost:4455/cypress/e2e/dom-content.html').should('be.visible')
cy.findByTestId('aut-url-input').invoke('val').should('contain', 'http://localhost:4455/cypress/e2e/dom-content.html')
cy.findByLabelText('Stats').should('not.exist')
cy.findByTestId('specs-list-panel').should('not.be.visible')
cy.findByTestId('reporter-panel').should('not.be.visible')
Expand Down
10 changes: 6 additions & 4 deletions packages/app/cypress/e2e/reporter_header.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,20 @@ describe('Reporter Header', () => {
it('filters the list of specs when searching for specs', () => {
cy.get('body').type('f')

cy.get('input').type('dom', { force: true })
cy.findByTestId('specs-list-panel').within(() => {
cy.get('input').as('searchInput').type('dom', { force: true })
})

cy.get('[data-cy="spec-file-item"]').should('have.length', 3)
.should('contain', 'dom-content.spec')

cy.get('input').clear()
cy.get('@searchInput').clear()

cy.get('[data-cy="spec-file-item"]').should('have.length', 3)

cy.get('input').type('asdf', { force: true })
cy.get('@searchInput').type('asdf', { force: true })

cy.get('[data-cy="spec-file-item"]').should('have.length', 0)
cy.findByTestId('spec-file-item').should('have.length', 0)
})
})

Expand Down
3 changes: 3 additions & 0 deletions packages/app/cypress/e2e/studio/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Cypress Studio Tests

These are the tests for the Cypress Studio feature. [Learn more here](https://docs.cypress.io/guides/references/cypress-studio).
24 changes: 24 additions & 0 deletions packages/app/cypress/e2e/studio/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function launchStudio () {
cy.scaffoldProject('experimental-studio')
cy.openProject('experimental-studio')
cy.startAppServer('e2e')
cy.visitApp()
cy.get(`[data-cy-row="spec.cy.js"]`).click()

cy.waitForSpecToFinish()

// Should not show "Studio Commands" until we've started a new Studio session.
cy.get('[data-cy="hook-name-studio commands"]').should('not.exist')

cy
.contains('visits a basic html page')
.closest('.runnable-wrapper')
.realHover()
.findByTestId('launch-studio')
.click()

// Studio re-executes spec before waiting for commands - wait for the spec to finish executing.
cy.waitForSpecToFinish()

cy.get('[data-cy="hook-name-studio commands"]').should('exist')
}
258 changes: 258 additions & 0 deletions packages/app/cypress/e2e/studio/studio.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { launchStudio } from './helper'

describe('Cypress Studio', () => {
it('updates an existing test with a click action', () => {
function addStudioClick (initialCount: number) {
cy.getAutIframe().within(() => {
cy.get('p').contains(`Count is ${initialCount}`)

// (1) First Studio action - get
cy.get('#increment')

// (2) Second Studio action - click
.realClick().then(() => {
cy.get('p').contains(`Count is ${initialCount + 1}`)
})
})
}

launchStudio()

cy.get('button').contains('Save Commands').should('be.disabled')

addStudioClick(0)

cy.get('button').contains('Save Commands').should('not.be.disabled')

cy.get('.studio-command-remove').click()

cy.get('button').contains('Save Commands').should('be.disabled')

addStudioClick(1)

cy.get('button').contains('Save Commands').should('not.be.disabled')

cy.get('[data-cy="hook-name-studio commands"]').closest('.hook-studio').within(() => {
cy.get('.command').should('have.length', 2)
// (1) Get Command
cy.get('.command-name-get').should('contain.text', '#increment')

// (2) Click Command
cy.get('.command-name-click').should('contain.text', 'click')
})

cy.get('button').contains('Save Commands').click()

cy.withCtx(async (ctx) => {
const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec.cy.js')

expect(spec.trim().replace(/\r/g, '')).to.eq(`
it('visits a basic html page', () => {
cy.visit('cypress/e2e/index.html')
/* ==== Generated with Cypress Studio ==== */
cy.get('#increment').click();
/* ==== End Cypress Studio ==== */
})`.trim())
})

// Studio re-executes the test after writing it file.
// It should pass
cy.waitForSpecToFinish({ passCount: 1 })

// Assert the commands we input via Studio are executed.
cy.get('.command-name-visit').within(() => {
cy.contains('visit')
cy.contains('cypress/e2e/index.html')
})

cy.get('.command-name-get').within(() => {
cy.contains('get')
cy.contains('#increment')
})

cy.get('.command-name-click').within(() => {
cy.contains('click')
})
})

it('writes a test with all kinds of assertions', () => {
function assertStudioHookCount (num: number) {
cy.get('[data-cy="hook-name-studio commands"]').closest('.hook-studio').within(() => {
cy.get('.command').should('have.length', num)
})
}

launchStudio()

cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
cy.get('.__cypress-studio-assertions-menu').shadow().contains('be enabled').realClick()
})
})

assertStudioHookCount(2)

cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
cy.get('.__cypress-studio-assertions-menu').shadow().contains('be visible').realClick()
})
})

assertStudioHookCount(4)

cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
cy.get('.__cypress-studio-assertions-menu').shadow().contains('have text').realHover()
cy.get('.__cypress-studio-assertions-menu').shadow().contains('Increment').realClick()
})
})

assertStudioHookCount(6)

cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
cy.get('.__cypress-studio-assertions-menu').shadow().contains('have id').realHover()
cy.get('.__cypress-studio-assertions-menu').shadow().contains('increment').realClick()
})
})

assertStudioHookCount(8)

cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
cy.get('.__cypress-studio-assertions-menu').shadow().contains('have attr').realHover()
cy.get('.__cypress-studio-assertions-menu').shadow().contains('onclick').realClick()
})
})

assertStudioHookCount(10)

cy.get('[data-cy="hook-name-studio commands"]').closest('.hook-studio').within(() => {
// 10 Commands - 5 assertions, each is a child of the subject's `cy.get`
cy.get('.command').should('have.length', 10)

// 5x cy.get Commands
cy.get('.command-name-get').should('have.length', 5)

// 5x Assertion Commands
cy.get('.command-name-assert').should('have.length', 5)

// (1) Assert Enabled
cy.get('.command-name-assert').should('contain.text', 'expect <button#increment> to be enabled')

// (2) Assert Visible
cy.get('.command-name-assert').should('contain.text', 'expect <button#increment> to be visible')

// (3) Assert Text
cy.get('.command-name-assert').should('contain.text', 'expect <button#increment> to have text Increment')

// (4) Assert Id
cy.get('.command-name-assert').should('contain.text', 'expect <button#increment> to have id increment')

// (5) Assert Attr
cy.get('.command-name-assert').should('contain.text', 'expect <button#increment> to have attr onclick with the value increment()')
})

cy.get('button').contains('Save Commands').click()

cy.withCtx(async (ctx) => {
const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec.cy.js')

expect(spec.trim().replace(/\r/g, '')).to.eq(`
it('visits a basic html page', () => {
cy.visit('cypress/e2e/index.html')
/* ==== Generated with Cypress Studio ==== */
cy.get('#increment').should('be.enabled');
cy.get('#increment').should('be.visible');
cy.get('#increment').should('have.text', 'Increment');
cy.get('#increment').should('have.id', 'increment');
cy.get('#increment').should('have.attr', 'onclick', 'increment()');
/* ==== End Cypress Studio ==== */
})`
.trim())
})
})

it('creates a test using Studio, but cancels and does not write to file', () => {
launchStudio()

cy.getAutIframe().within(() => {
cy.get('p').contains('Count is 0')

// (1) First Studio action - get
cy.get('#increment')

// (2) Second Studio action - click
.realClick().then(() => {
cy.get('p').contains('Count is 1')
})
})

cy.get('[data-cy="hook-name-studio commands"]').closest('.hook-studio').within(() => {
cy.get('.command').should('have.length', 2)
// (1) Get Command
cy.get('.command-name-get').should('contain.text', '#increment')

// (2) Click Command
cy.get('.command-name-click').should('contain.text', 'click')
})

cy.get('[data-cy="hook-name-studio commands"]').should('exist')

cy.get('a').contains('Cancel').click()

// Cyprss 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.get('[data-cy="hook-name-studio commands"]').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(`
it('visits a basic html page', () => {
cy.visit('cypress/e2e/index.html')
})`.trim())
})
})

// TODO: Can we somehow do the "Create Test" workflow within Cypress in Cypress?
it('creates a brand new test', () => {
cy.scaffoldProject('experimental-studio')
cy.openProject('experimental-studio')
cy.startAppServer('e2e')
cy.visitApp()
cy.get(`[title="empty.cy.js"]`).should('be.visible').click()

cy.waitForSpecToFinish()

cy.contains('Create test with Cypress Studio').click()
cy.get('[data-cy="aut-url"]').as('urlPrompt')

cy.get('@urlPrompt').within(() => {
cy.contains('Continue ➜').should('be.disabled')
})

cy.get('@urlPrompt').type('http://localhost:4455/cypress/e2e/index.html')

cy.get('@urlPrompt').within(() => {
cy.contains('Continue ➜').should('not.be.disabled')
cy.contains('Cancel').click()
})

// TODO: Can we somehow do the "Create Test" workflow within Cypress in Cypress?
// If we hit "Continue" here, it updates the domain (as expected) but since we are
// Cypress in Cypress, it redirects us the the spec page, which is not what normally
// would happen in production.
})
})
Loading

0 comments on commit 72b8a65

Please sign in to comment.