Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Cypress Studio for Cypress 10 #23544

Merged
merged 30 commits into from
Aug 29, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c38e019
chore: wire up Cypress Studio (#23413)
lmiller1990 Aug 23, 2022
5089992
Merge remote-tracking branch 'origin/develop' into feat/studio-cypres…
lmiller1990 Aug 23, 2022
60dc66c
fix a bug in the assertion API
lmiller1990 Aug 23, 2022
451e382
fix bug in assertions [skip ci]
lmiller1990 Aug 23, 2022
268d5dc
wip - bugs [skip ci]
lmiller1990 Aug 23, 2022
8d0e0c8
feat: add experimentalStudio flag back (#23506)
lmiller1990 Aug 23, 2022
abf8f07
Merge remote-tracking branch 'origin/develop' into feat/studio-cypres…
lmiller1990 Aug 24, 2022
e90cca7
Merge remote-tracking branch 'origin/develop' into feat/studio-cypres…
lmiller1990 Aug 25, 2022
849409c
chore: Add Studio UI to Cypress 10 (#23537)
astone123 Aug 25, 2022
cef5a5c
test: studio e2e tests (#23546)
lmiller1990 Aug 25, 2022
973f93e
chore: UI feedback
astone123 Aug 25, 2022
821179d
fix race condition
lmiller1990 Aug 26, 2022
f3728b1
update tests
lmiller1990 Aug 26, 2022
3086106
rename test
lmiller1990 Aug 26, 2022
3923cc1
improve types in reporter
lmiller1990 Aug 26, 2022
507f3b3
remove dead code
lmiller1990 Aug 26, 2022
5ffeae7
improve tests
lmiller1990 Aug 26, 2022
ffc10d4
merge tests into one spec
lmiller1990 Aug 26, 2022
13fde97
chore: Cap instruction modal width; exit studio mode when new spec is…
astone123 Aug 26, 2022
21d213f
chore: Only render studio error when test has failed; add test for st…
astone123 Aug 26, 2022
5370f8b
Merge branch 'develop' into feat/studio-cypress-10
astone123 Aug 26, 2022
d52d637
correctly check if command is studio or not
lmiller1990 Aug 29, 2022
24d8718
improve specs and hopefully reduce flake
lmiller1990 Aug 29, 2022
c6eab49
communicate studio state from app->reporter
lmiller1990 Aug 29, 2022
827d4f5
receive studio save state validity from app
lmiller1990 Aug 29, 2022
d1db435
fix test
lmiller1990 Aug 29, 2022
81ef719
Merge branch 'develop' into feat/studio-cypress-10
astone123 Aug 29, 2022
c48827f
improve test coverage
lmiller1990 Aug 29, 2022
14dc87f
Merge remote-tracking branch 'origin/develop' into feat/studio-cypres…
lmiller1990 Aug 29, 2022
06debea
fix external link
lmiller1990 Aug 29, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -3060,7 +3065,7 @@ declare namespace Cypress {
viteConfig?: Omit<Exclude<PickConfigOpt<'viteConfig'>, undefined>, 'base' | 'root'>
}

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).
25 changes: 25 additions & 0 deletions packages/app/cypress/e2e/studio/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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.visit(`http://localhost:4455/__/#/specs/runner?file=cypress/e2e/spec.cy.js`)

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')
}
242 changes: 242 additions & 0 deletions packages/app/cypress/e2e/studio/studio.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { launchStudio } from './helper'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These test are failing when I run them locally, but are passing in CI? Is there something I'm missing when running these tests?

Screen Shot 2022-08-26 at 9 55 31 AM

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking into this... this test is also failing for me but for a different reason

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the tests in this file are really flakey for me... I'm still looking into it. Seems like there's some situations where after launchStudio runs, we're not in studio mode. Probably a race condition of sorts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't get this exact error. I tried a little commit 24d8718 to reduce flake. These run reliably for me, and on CI 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm still seeing them flake a bunch in studio.cy.ts. I've run them several times and can't get them all to pass at once 🤔 Must just be an issue with my machine. If they're running well in CI then I'd say this isn't a blocker


describe('Cypress Studio', () => {
it('updates an existing test with a click action', () => {
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('button').contains('Save Commands').click()

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

expect(spec.trim()).to.eq(`
astone123 marked this conversation as resolved.
Show resolved Hide resolved
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()).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()).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.visit(`http://localhost:4455/__/#/specs/runner?file=cypress/e2e/empty.cy.js`)

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.
})
})
5 changes: 2 additions & 3 deletions packages/app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import 'virtual:windi.css'
import urql from '@urql/vue'
import App from './App.vue'
import { makeUrqlClient } from '@packages/frontend-shared/src/graphql/urqlClient'
import { decodeBase64Unicode } from '@packages/frontend-shared/src/utils/base64'
import { createI18n } from '@cy/i18n'
import { createRouter } from './router/router'
import { injectBundle } from './runner/injectBundle'
import { createPinia } from './store'
import Toast, { POSITION } from 'vue-toastification'
import 'vue-toastification/dist/index.css'
import { createWebsocket } from './runner'
import { createWebsocket, getRunnerConfigFromWindow } from './runner'

// set a global so we can run
// conditional code in the vite branch
Expand All @@ -21,7 +20,7 @@ window.__vite__ = true

const app = createApp(App)

const config = JSON.parse(decodeBase64Unicode(window.__CYPRESS_CONFIG__.base64Config)) as Cypress.Config
const config = getRunnerConfigFromWindow()

const ws = createWebsocket(config.socketIoRoute)

Expand Down
Loading