diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 2826c4fcd71e..a9c7e65d5b92 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -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. @@ -3073,7 +3078,7 @@ declare namespace Cypress { } } - interface ComponentConfigOptions extends Omit { + interface ComponentConfigOptions extends Omit { devServer: DevServerFn | DevServerConfigOptions devServerConfig?: ComponentDevServerOpts /** diff --git a/packages/app/cypress.config.ts b/packages/app/cypress.config.ts index 7c42687ab789..4c12cd5dfffb 100644 --- a/packages/app/cypress.config.ts +++ b/packages/app/cypress.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ }, }, 'e2e': { + experimentalStudio: true, baseUrl: 'http://localhost:5555', supportFile: 'cypress/e2e/support/e2eSupport.ts', async setupNodeEvents (on, config) { diff --git a/packages/app/cypress/component/support/ctSupport.ts b/packages/app/cypress/component/support/ctSupport.ts index 8cc2a4393a75..afcb5e02a8f7 100644 --- a/packages/app/cypress/component/support/ctSupport.ts +++ b/packages/app/cypress/component/support/ctSupport.ts @@ -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(Object.create(null), { get: (obj, prop) => { throw Error(`Cannot access ${String(prop)} on StubWebsocket!`) @@ -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, ) } @@ -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, ) } diff --git a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts index c4a1da589e05..c8605e3b20a3 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts @@ -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() diff --git a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts index 1c3cb50838e4..1aa3a064a65f 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts @@ -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') diff --git a/packages/app/cypress/e2e/reporter_header.cy.ts b/packages/app/cypress/e2e/reporter_header.cy.ts index 3f5186584463..679c9e29b6d7 100644 --- a/packages/app/cypress/e2e/reporter_header.cy.ts +++ b/packages/app/cypress/e2e/reporter_header.cy.ts @@ -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) }) }) diff --git a/packages/app/cypress/e2e/studio/README.md b/packages/app/cypress/e2e/studio/README.md new file mode 100644 index 000000000000..f27e48a18c12 --- /dev/null +++ b/packages/app/cypress/e2e/studio/README.md @@ -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). \ No newline at end of file diff --git a/packages/app/cypress/e2e/studio/helper.ts b/packages/app/cypress/e2e/studio/helper.ts new file mode 100644 index 000000000000..59a98e57f520 --- /dev/null +++ b/packages/app/cypress/e2e/studio/helper.ts @@ -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') +} diff --git a/packages/app/cypress/e2e/studio/studio.cy.ts b/packages/app/cypress/e2e/studio/studio.cy.ts new file mode 100644 index 000000000000..5e9df380b3a0 --- /dev/null +++ b/packages/app/cypress/e2e/studio/studio.cy.ts @@ -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 to be enabled') + + // (2) Assert Visible + cy.get('.command-name-assert').should('contain.text', 'expect to be visible') + + // (3) Assert Text + cy.get('.command-name-assert').should('contain.text', 'expect to have text Increment') + + // (4) Assert Id + cy.get('.command-name-assert').should('contain.text', 'expect to have id increment') + + // (5) Assert Attr + cy.get('.command-name-assert').should('contain.text', 'expect 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. + }) +}) diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts index 4404c76e8474..823e76761253 100644 --- a/packages/app/src/main.ts +++ b/packages/app/src/main.ts @@ -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 @@ -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) diff --git a/packages/app/src/navigation/KeyboardBindingsModal.vue b/packages/app/src/navigation/KeyboardBindingsModal.vue index fb1457dc70eb..7df4956b3193 100644 --- a/packages/app/src/navigation/KeyboardBindingsModal.vue +++ b/packages/app/src/navigation/KeyboardBindingsModal.vue @@ -5,7 +5,7 @@ :title="t('sidebar.keyboardShortcuts.title')" :model-value="show" data-cy="keyboard-modal" - help-link="" + :no-help="true" @update:model-value="emits('close')" >
    diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx b/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx index a7bd4efc18fa..8682511b6546 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx @@ -125,7 +125,7 @@ describe('SpecRunnerHeaderOpenMode', { viewportHeight: 500 }, () => { }, }) - cy.contains(url).should('exist').should('have.attr', 'href', url) + cy.findByTestId('aut-url-input').invoke('val').should('contain', url) cy.percySnapshot() }) diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue index 3482e870da5c..c02baf0b4b15 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue @@ -23,14 +23,25 @@ > - - {{ autStore.url }} - + +
    + + () + const props = defineProps<{ gql: SpecRunnerHeaderFragment eventManager: EventManager @@ -209,6 +231,14 @@ const displayScale = computed(() => { return autStore.scale < 1 ? `${Math.round(autStore.scale * 100) }%` : 0 }) +const autUrl = computed(() => { + if (studioStore.isActive && studioStore.url) { + return studioStore.url + } + + return autStore.url +}) + const selectorPlaygroundStore = useSelectorPlaygroundStore() const togglePlayground = () => _togglePlayground(autIframe) @@ -220,4 +250,21 @@ const activeSpecPath = specStore.activeSpec?.absolute const isDisabled = computed(() => autStore.isRunning || autStore.isLoading) +function setStudioUrl (event: Event) { + const url = (event.currentTarget as HTMLInputElement).value + + urlInProgress.value = url +} + +function visitUrl () { + studioStore.visitUrl(urlInProgress.value) +} + +function openInNewTab () { + if (!autStore.url || studioStore.isActive) { + return + } + + window.open(autStore.url, '_blank')?.focus() +} diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index efa65c8d5298..4bfa040d18cd 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -1,4 +1,12 @@