diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md
index 5231540f3f..b1d4a7d6ae 100644
--- a/cli/CHANGELOG.md
+++ b/cli/CHANGELOG.md
@@ -10,6 +10,7 @@ _Released 3/11/2025 (PENDING)_
**Misc:**
- Additional CLI options will be displayed in the terminal for some Cloud error messages. Addressed in [#31211](https://github.com/cypress-io/cypress/pull/31211).
+- Updated Cypress Studio with url routing to support maintaining state when reloading. Addresses [#31000](https://github.com/cypress-io/cypress/issues/31000) and [#30996](https://github.com/cypress-io/cypress/issues/30996).
**Dependency Updates:**
diff --git a/packages/app/cypress.config.ts b/packages/app/cypress.config.ts
index 8b0331d040..ca9991e0a1 100644
--- a/packages/app/cypress.config.ts
+++ b/packages/app/cypress.config.ts
@@ -26,9 +26,13 @@ export default defineConfig({
framework: 'vue',
},
},
+ hosts: {
+ 'foobar.com': '127.0.0.1',
+ },
'e2e': {
experimentalRunAllSpecs: true,
experimentalStudio: true,
+ experimentalOriginDependencies: true,
baseUrl: 'http://localhost:5555',
supportFile: 'cypress/e2e/support/e2eSupport.ts',
async setupNodeEvents (on, config) {
diff --git a/packages/app/cypress/e2e/studio/helper.ts b/packages/app/cypress/e2e/studio/helper.ts
index fbc25ac4b4..c8166b71eb 100644
--- a/packages/app/cypress/e2e/studio/helper.ts
+++ b/packages/app/cypress/e2e/studio/helper.ts
@@ -1,25 +1,36 @@
-export function launchStudio () {
+export function launchStudio ({ specName = 'spec.cy.js', createNewTest = false, cliArgs = [''] } = {}) {
cy.scaffoldProject('experimental-studio')
- cy.openProject('experimental-studio')
+ cy.openProject('experimental-studio', cliArgs)
cy.startAppServer('e2e')
cy.visitApp()
cy.specsPageIsVisible()
- cy.get(`[data-cy-row="spec.cy.js"]`).click()
+ cy.get(`[data-cy-row="${specName}"]`).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')
+ if (createNewTest) {
+ cy.contains('studio functionality').as('item')
+ } else {
+ cy.contains('visits a basic html page').as('item')
+ }
+
+ cy.get('@item')
+ .closest('.runnable-wrapper').as('runnable-wrapper')
.realHover()
+
+ cy.get('@runnable-wrapper')
.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')
+ if (createNewTest) {
+ cy.get('span.runnable-title').contains('New Test').should('exist')
+ } else {
+ 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
index f332f231b5..6d94395124 100644
--- a/packages/app/cypress/e2e/studio/studio.cy.ts
+++ b/packages/app/cypress/e2e/studio/studio.cy.ts
@@ -1,26 +1,32 @@
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}`)
+ function incrementCounter (initialCount: number) {
+ cy.getAutIframe().within(() => {
+ cy.get('p').contains(`Count is ${initialCount}`)
- // (1) First Studio action - get
- cy.get('#increment')
+ // (1) First Studio action - get
+ cy.get('#increment')
- // (2) Second Studio action - click
- .realClick().then(() => {
- cy.get('p').contains(`Count is ${initialCount + 1}`)
- })
+ // (2) Second Studio action - click
+ .realClick().then(() => {
+ cy.get('p').contains(`Count is ${initialCount + 1}`)
})
- }
+ })
+ }
+ function assertStudioHookCommandCount (num: number) {
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ cy.get('.command').should('have.length', num)
+ })
+ }
+
+ it('updates an existing test with an action', () => {
launchStudio()
cy.get('button').contains('Save Commands').should('be.disabled')
- addStudioClick(0)
+ incrementCounter(0)
cy.get('button').contains('Save Commands').should('not.be.disabled')
@@ -28,12 +34,13 @@ describe('Cypress Studio', () => {
cy.get('button').contains('Save Commands').should('be.disabled')
- addStudioClick(1)
+ incrementCounter(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)
+ assertStudioHookCommandCount(2)
+
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
// (1) Get Command
cy.get('.command-name-get').should('contain.text', '#increment')
@@ -47,11 +54,13 @@ describe('Cypress Studio', () => {
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 ==== */
+describe('studio functionality', () => {
+ it('visits a basic html page', () => {
+ cy.visit('cypress/e2e/index.html')
+ /* ==== Generated with Cypress Studio ==== */
+ cy.get('#increment').click();
+ /* ==== End Cypress Studio ==== */
+ })
})`.trim())
})
@@ -75,13 +84,69 @@ it('visits a basic html page', () => {
})
})
- 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)
- })
- }
+ it('updates an existing test with an action using studio toolbar', () => {
+ launchStudio()
+ cy.get('button').contains('Save Commands').should('be.disabled')
+
+ incrementCounter(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')
+
+ incrementCounter(1)
+
+ cy.get('button').contains('Save Commands').should('not.be.disabled')
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ // (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.findByTestId('studio-toolbar-controls').findByTestId('save').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(`
+describe('studio functionality', () => {
+ 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('updates an existing test with assertions', () => {
launchStudio()
cy.getAutIframe().within(() => {
@@ -90,14 +155,14 @@ it('visits a basic html page', () => {
})
})
- assertStudioHookCount(2)
+ assertStudioHookCommandCount(2)
cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
cy.get('.__cypress-studio-assertions-menu').shadow().contains('be visible').realClick()
})
})
- assertStudioHookCount(4)
+ assertStudioHookCommandCount(4)
cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
@@ -106,7 +171,7 @@ it('visits a basic html page', () => {
})
})
- assertStudioHookCount(6)
+ assertStudioHookCommandCount(6)
cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
@@ -115,7 +180,7 @@ it('visits a basic html page', () => {
})
})
- assertStudioHookCount(8)
+ assertStudioHookCommandCount(8)
cy.getAutIframe().within(() => {
cy.get('#increment').rightclick().then(() => {
@@ -124,9 +189,9 @@ it('visits a basic html page', () => {
})
})
- assertStudioHookCount(10)
+ assertStudioHookCommandCount(10)
- cy.get('[data-cy="hook-name-studio commands"]').closest('.hook-studio').within(() => {
+ cy.findByTestId('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)
@@ -158,37 +223,28 @@ it('visits a basic html page', () => {
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())
+describe('studio functionality', () => {
+ 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', () => {
+ it('does not update the test when it is cancelled', () => {
launchStudio()
- cy.getAutIframe().within(() => {
- cy.get('p').contains('Count is 0')
+ incrementCounter(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)
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
// (1) Get Command
cy.get('.command-name-get').should('contain.text', '#increment')
@@ -196,11 +252,9 @@ it('visits a basic html page', () => {
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.
+ // Cypress re-runs after you cancel Studio.
// Original spec should pass
cy.waitForSpecToFinish({ passCount: 1 })
@@ -212,21 +266,107 @@ it('visits a basic html page', () => {
cy.contains('cypress/e2e/index.html')
})
- cy.get('[data-cy="hook-name-studio commands"]').should('not.exist')
+ 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')
// 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')
+describe('studio functionality', () => {
+ 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', () => {
+ it('does not update the test when studio is closed using studio toolbar', () => {
+ launchStudio()
+
+ incrementCounter(0)
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ // (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.findByTestId('studio-toolbar-controls').findByTestId('close-studio').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.withCtx(async (ctx) => {
+ const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec.cy.js')
+
+ // No change, since we closed studio
+ 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('removes pending commands when restarting studio', () => {
+ launchStudio()
+
+ 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.')
+ })
+
+ incrementCounter(0)
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ // (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.findByTestId('studio-toolbar').findByTestId('restart-studio').click()
+
+ cy.waitForSpecToFinish()
+
+ // all of the pending studio commands should have been 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.')
+ })
+
+ 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('creates a new test from an empty spec', () => {
cy.scaffoldProject('experimental-studio')
cy.openProject('experimental-studio')
cy.startAppServer('e2e')
@@ -237,26 +377,290 @@ it('visits a basic html page', () => {
cy.waitForSpecToFinish()
cy.contains('Create test with Cypress Studio').click()
- cy.get('[data-cy="aut-url"]').as('urlPrompt')
+ cy.findByTestId('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').type('/cypress/e2e/index.html')
cy.get('@urlPrompt').within(() => {
- cy.contains('Continue ➜').should('not.be.disabled')
- cy.contains('Cancel').click()
+ cy.contains('Continue ➜').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.
+ cy.get('button').contains('Save Commands').click()
+
+ // the save button is disabled until we add a test name
+ cy.get('button[type=submit]').should('be.disabled')
+
+ cy.get('#testName').type('new-test')
+
+ cy.get('button[type=submit]').click()
+
+ // Cypress re-runs after the new test is saved.
+ cy.waitForSpecToFinish({ passCount: 1 })
+
+ cy.get('.command').should('have.length', 1)
+ 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.withCtx(async (ctx) => {
+ const spec = await ctx.actions.file.readFileInProject('cypress/e2e/empty.cy.js')
+
+ expect(spec.trim().replace(/\r/g, '')).to.equal(`
+/* ==== Test Created with Cypress Studio ==== */
+it('new-test', function() {
+ /* ==== Generated with Cypress Studio ==== */
+ cy.visit('/cypress/e2e/index.html');
+ /* ==== End Cypress Studio ==== */
+});
+`.trim())
+ })
})
- it('shows menu and submenu correctly', () => {
+ it('creates a new test for an existing spec', () => {
+ launchStudio({ createNewTest: true })
+
+ cy.findByTestId('aut-url').as('urlPrompt')
+
+ cy.get('@urlPrompt').within(() => {
+ cy.contains('Continue ➜').should('be.disabled')
+ })
+
+ cy.get('@urlPrompt').type('/cypress/e2e/index.html')
+
+ cy.get('@urlPrompt').within(() => {
+ cy.contains('Continue ➜').click()
+ })
+
+ cy.get('button').contains('Save Commands').click()
+
+ // the save button is disabled until we add a test name
+ cy.get('button[type=submit]').should('be.disabled')
+
+ cy.get('#testName').type('new-test')
+
+ cy.get('button[type=submit]').click()
+
+ // Cypress re-runs after the new test is saved.
+ cy.waitForSpecToFinish({ passCount: 2 })
+
+ cy.contains('new-test').click()
+ cy.get('.command').should('have.length', 1)
+ 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.withCtx(async (ctx) => {
+ const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec.cy.js')
+
+ expect(spec.trim().replace(/\r/g, '')).to.equal(`
+describe('studio functionality', () => {
+ it('visits a basic html page', () => {
+ cy.visit('cypress/e2e/index.html')
+ })
+
+ /* ==== Test Created with Cypress Studio ==== */
+ it('new-test', function() {
+ /* ==== Generated with Cypress Studio ==== */
+ cy.visit('/cypress/e2e/index.html');
+ /* ==== End Cypress Studio ==== */
+ });
+})
+`.trim())
+ })
+ })
+
+ // TODO: this test fails in CI but passes locally
+ // http://github.com/cypress-io/cypress/issues/31248
+ it.skip('creates a new test with a url that changes top', function () {
+ launchStudio({ specName: 'spec-w-foobar.cy.js', createNewTest: true })
+
+ cy.origin('http://foobar.com:4455', () => {
+ Cypress.require('../support/execute-spec')
+ Cypress.require('cypress-real-events/support')
+ Cypress.require('@packages/frontend-shared/cypress/support/e2e')
+ })
+
+ cy.findByTestId('aut-url').as('urlPrompt')
+
+ cy.get('@urlPrompt').within(() => {
+ cy.contains('Continue ➜').should('be.disabled')
+ })
+
+ // go to a cross-origin url
+ cy.get('@urlPrompt').type('http://foobar.com:4455/cypress/e2e/index.html')
+
+ cy.get('@urlPrompt').within(() => {
+ cy.contains('Continue ➜').click()
+ })
+
+ cy.origin('http://foobar.com:4455', () => {
+ cy.get('button').contains('Save Commands').click()
+
+ // the save button is disabled until we add a test name
+ cy.get('button[type=submit]').should('be.disabled')
+
+ cy.get('#testName').type('new-test')
+
+ cy.get('button[type=submit]').click()
+
+ // Cypress re-runs after the new test is saved.
+ cy.waitForSpecToFinish({ passCount: 2 })
+
+ cy.contains('new-test').click()
+ cy.get('.command').should('have.length', 1)
+ 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.withCtx(async (ctx) => {
+ const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec-w-foobar.cy.js')
+
+ expect(spec.trim().replace(/\r/g, '')).to.equal(`
+describe('studio functionality', () => {
+ beforeEach(() => {
+ cy.intercept('GET', 'http://foobar.com:4455/cypress/e2e/index.html', {
+ statusCode: 200,
+ body: '
hello world
',
+ headers: {
+ 'content-type': 'text/html',
+ },
+ })
+ })
+
+ it('visits a basic html page', () => {
+ cy.visit('cypress/e2e/index.html')
+ })
+
+ /* ==== Test Created with Cypress Studio ==== */
+ it('new-test', function() {
+ /* ==== Generated with Cypress Studio ==== */
+ cy.visit('http://foobar.com:4455/cypress/e2e/index.html');
+ /* ==== End Cypress Studio ==== */
+ });
+})`.trim())
+ })
+ })
+
+ it('creates a new test for an existing spec with the url already defined', () => {
+ launchStudio({ specName: 'spec-w-visit.cy.js', createNewTest: true })
+
+ incrementCounter(0)
+
+ cy.get('button').contains('Save Commands').click()
+
+ // the save button is disabled until we add a test name
+ cy.get('button[type=submit]').should('be.disabled')
+
+ cy.get('#testName').type('new-test')
+
+ cy.get('button[type=submit]').click()
+
+ // Cypress re-runs after the new test is saved.
+ cy.waitForSpecToFinish({ passCount: 2 })
+
+ cy.contains('new-test').click()
+
+ cy.get('.command').should('have.length', 3)
+
+ // 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')
+ })
+
+ cy.findByTestId('hook-name-studio commands').should('not.exist')
+
+ cy.withCtx(async (ctx) => {
+ const spec = await ctx.actions.file.readFileInProject('cypress/e2e/spec-w-visit.cy.js')
+
+ expect(spec.trim().replace(/\r/g, '')).to.equal(`
+describe('studio functionality', () => {
+ beforeEach(() => {
+ cy.visit('cypress/e2e/index.html')
+ })
+
+ it('visits a basic html page', () => {
+ cy.get('h1').should('have.text', 'Hello, Studio!')
+ })
+
+ /* ==== Test Created with Cypress Studio ==== */
+ it('new-test', function() {
+ /* ==== Generated with Cypress Studio ==== */
+ cy.get('#increment').click();
+ /* ==== End Cypress Studio ==== */
+ });
+})
+`.trim())
+ })
+ })
+
+ it('does not create a new test if the Save test modal is closed', () => {
+ cy.scaffoldProject('experimental-studio')
+ cy.openProject('experimental-studio')
+ cy.startAppServer('e2e')
+ cy.visitApp()
+ cy.specsPageIsVisible()
+ cy.get(`[title="empty.cy.js"]`).should('be.visible').click()
+
+ cy.waitForSpecToFinish()
+
+ cy.contains('Create test with Cypress Studio').click()
+ cy.findByTestId('aut-url').as('urlPrompt')
+
+ cy.get('@urlPrompt').within(() => {
+ cy.contains('Continue ➜').should('be.disabled')
+ })
+
+ cy.get('@urlPrompt').type('/cypress/e2e/index.html')
+
+ cy.get('@urlPrompt').within(() => {
+ cy.contains('Continue ➜').click()
+ })
+
+ cy.getAutIframe().within(() => {
+ cy.get('p').contains('Count is 0')
+ cy.get('#increment').realClick()
+ })
+
+ cy.get('button').contains('Save Commands').click()
+
+ cy.get('#testName').type('new-test')
+
+ cy.get('button[aria-label=Close]').click()
+
+ // all of the existing studio commands should still be there since we didn't save
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ cy.get('.command').should('have.length', 3)
+ cy.get('.command-name-visit').should('contain.text', '/cypress/e2e/index.html')
+ cy.get('.command-name-get').should('contain.text', '#increment')
+ cy.get('.command-name-click').should('contain.text', 'click')
+ })
+ })
+
+ it('shows assertions menu and submenu correctly', () => {
launchStudio()
cy.getAutIframe().within(() => {
@@ -278,4 +682,409 @@ it('visits a basic html page', () => {
.should('be.visible')
})
})
+
+ it('copies the studio commands to the clipboard', () => {
+ launchStudio()
+
+ incrementCounter(0)
+
+ // spy on the clipboard to check if the commands are copied
+ cy.window().its('navigator.clipboard').then((clipboard) => {
+ cy.spy(clipboard, 'writeText').as('writeText')
+ })
+
+ cy.get('button.studio-copy').click()
+
+ cy.get('@writeText').should('have.been.calledOnceWith',
+`/* ==== Generated with Cypress Studio ==== */
+cy.get('#increment').click();
+/* ==== End Cypress Studio ==== */`)
+ })
+
+ it('copies the studio commands to the clipboard using studio toolbar', () => {
+ launchStudio()
+
+ incrementCounter(0)
+
+ // spy on the clipboard to check if the commands are copied
+ cy.window().its('navigator.clipboard').then((clipboard) => {
+ cy.spy(clipboard, 'writeText').as('writeText')
+ })
+
+ cy.findByTestId('studio-toolbar-controls').findByTestId('copy-commands').click()
+
+ cy.get('@writeText').should('have.been.calledOnceWith',
+`/* ==== Generated with Cypress Studio ==== */
+cy.get('#increment').click();
+/* ==== End Cypress Studio ==== */`)
+ })
+
+ it('removes pending commands if the page is reloaded', () => {
+ launchStudio()
+
+ incrementCounter(0)
+
+ cy.findByTestId('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.window().then((win) => {
+ // calling cy.reload() or win.location.reload() confuses the test runner
+ // and causes it to go to the spec list of the main runner instead of reloading the inner runner,
+ // so we need to navigate to the same url to trigger a reload
+ // eslint-disable-next-line no-self-assign
+ win.location.href = win.location.href
+ })
+
+ cy.waitForSpecToFinish()
+
+ // 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.')
+ })
+
+ cy.findByTestId('studio-toolbar-controls').findByTestId('save').should('be.disabled')
+ })
+
+ it('removes pending commands when rerunning the test', () => {
+ launchStudio()
+
+ incrementCounter(0)
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ // (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[aria-label="Rerun all tests"]').click()
+
+ cy.waitForSpecToFinish()
+
+ // 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.')
+ })
+
+ cy.findByTestId('studio-toolbar-controls').findByTestId('save').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()
+
+ // go back to the specs page
+ cy.findByTestId('sidebar-link-specs-page').click()
+ cy.contains('spec.cy.js').click()
+
+ 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=')
+ })
+
+ it('exits studio mode if the spec is changed on the file system', () => {
+ launchStudio()
+
+ incrementCounter(0)
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ // (1) Get Command
+ cy.get('.command-name-get').should('contain.text', '#increment')
+
+ // (2) Click Command
+ cy.get('.command-name-click').should('contain.text', 'click')
+ })
+
+ // update the spec on the file system
+ cy.withCtx(async (ctx) => {
+ await ctx.actions.file.writeFileInProject('cypress/e2e/spec.cy.js', `
+describe('studio functionality', () => {
+ it('visits a basic html page', () => {
+ cy.visit('cypress/e2e/index.html')
+
+ // new command
+ cy.get('h1').should('have.text', 'Hello, Studio!')
+ })
+})`)
+ })
+
+ cy.waitForSpecToFinish({ passCount: 1 })
+
+ cy.findByTestId('hook-name-studio commands').should('not.exist')
+
+ // assert the commands we wrote directly to the spec 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('h1')
+ })
+
+ cy.get('.command-name-assert').within(() => {
+ cy.contains('assert')
+ cy.contains('expected to have text Hello, Studio!')
+ })
+ })
+
+ it('exits studio mode if the spec is removed on the file system', () => {
+ launchStudio()
+
+ incrementCounter(0)
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('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')
+ })
+
+ // update the spec on the file system
+ cy.withCtx(async (ctx) => {
+ await ctx.actions.file.removeFileInProject('cypress/e2e/spec.cy.js')
+ })
+
+ cy.location().its('hash').should('equal', '#/specs').and('not.contain', 'testId=').and('not.contain', 'studio=')
+ cy.findByTestId('alert').should('contain.text', 'Spec not found')
+ cy.findByTestId('alert-body').should('contain.text', 'There is no spec matching the following location: cypress/e2e/spec.cy.js')
+ })
+
+ it('appends the studio commands to the commands added to the test on the file system when file watching is disabled', () => {
+ launchStudio({ cliArgs: ['--config', 'watchForFileChanges=false'] })
+
+ incrementCounter(0)
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ // (1) Get Command
+ cy.get('.command-name-get').should('contain.text', '#increment')
+
+ // (2) Click Command
+ cy.get('.command-name-click').should('contain.text', 'click')
+ })
+
+ // update the spec on the file system
+ cy.withCtx(async (ctx) => {
+ await ctx.actions.file.writeFileInProject('cypress/e2e/spec.cy.js', `
+describe('studio functionality', () => {
+ it('visits a basic html page', () => {
+ cy.visit('cypress/e2e/index.html')
+
+ // new command
+ cy.get('h1').should('have.text', 'Hello, Studio!')
+ })
+})`)
+ })
+
+ cy.get('button').contains('Save Commands').click()
+
+ cy.waitForSpecToFinish({ passCount: 1 })
+
+ cy.findByTestId('hook-name-studio commands').should('not.exist')
+
+ // assert the commands we wrote directly to the spec are executed
+ cy.get('.command-name-visit').within(() => {
+ cy.contains('visit')
+ cy.contains('cypress/e2e/index.html')
+ })
+
+ cy.get('.command-name-get').eq(0).within(() => {
+ cy.contains('get')
+ cy.contains('h1')
+ })
+
+ cy.get('.command-name-assert').within(() => {
+ cy.contains('assert')
+ cy.contains('expected to have text Hello, Studio!')
+ })
+
+ cy.get('.command-name-get').eq(1).within(() => {
+ cy.contains('get')
+ cy.contains('#increment')
+ })
+
+ cy.get('.command-name-click').within(() => {
+ cy.contains('click')
+ })
+ })
+
+ it('remains in studio mode when the test name is changed on the file system and file watching is disabled', () => {
+ launchStudio({ cliArgs: ['--config', 'watchForFileChanges=false'] })
+
+ incrementCounter(0)
+
+ assertStudioHookCommandCount(2)
+ cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
+ // (1) Get Command
+ cy.get('.command-name-get').should('contain.text', '#increment')
+
+ // (2) Click Command
+ cy.get('.command-name-click').should('contain.text', 'click')
+ })
+
+ // update the spec on the file system by changing the
+ // test name which will cause the save to fail since
+ // the test won't be found
+ cy.withCtx(async (ctx) => {
+ await ctx.actions.file.writeFileInProject('cypress/e2e/spec.cy.js', `
+describe('studio functionality', () => {
+ it('CHANGED - visits a basic html page', () => {
+ cy.visit('cypress/e2e/index.html')
+
+ // new command
+ cy.get('h1').should('have.text', 'Hello, Studio!')
+ })
+})`)
+ })
+
+ cy.findByTestId('studio-toolbar-controls').should('exist')
+
+ cy.get('button').contains('Save Commands').click()
+
+ cy.findByTestId('studio-toolbar-controls').should('exist')
+ cy.get('button').contains('Save Commands')
+
+ cy.findByTestId('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')
+ })
+ })
+
+ it('removes url parameters when selecting a different spec', () => {
+ 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.location().its('hash').should('not.contain', 'testId=').and('not.contain', 'studio=')
+ })
+
+ it('removes url parameters when going to a different page', () => {
+ launchStudio()
+
+ cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=')
+
+ // go to the runs page
+ cy.findByTestId('sidebar-link-runs-page').click()
+
+ cy.location().its('hash').should('contain', '/runs').and('not.contain', 'testId=').and('not.contain', 'studio=')
+ })
+
+ it('updates the url with the testId and studio parameters when entering studio with a test', () => {
+ launchStudio()
+
+ cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=')
+ })
+
+ it('update the url with the suiteId and studio parameters when entering studio with a suite', () => {
+ launchStudio({ createNewTest: true })
+
+ cy.location().its('hash').should('contain', 'suiteId=r2').and('contain', 'studio=')
+ })
+
+ it('removes the studio url parameters when saving test changes', () => {
+ launchStudio()
+
+ cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=')
+
+ cy.getAutIframe().within(() => {
+ cy.get('#increment').realClick()
+ })
+
+ cy.get('button').contains('Save Commands').click()
+
+ cy.location().its('hash').and('not.contain', 'testId=').and('not.contain', 'studio=')
+ })
+
+ it('removes the studio url parameters when saving a new test', () => {
+ launchStudio({ specName: 'spec-w-visit.cy.js', createNewTest: true })
+
+ cy.location().its('hash').should('contain', 'suiteId=r2').and('contain', 'studio=')
+
+ cy.getAutIframe().within(() => {
+ cy.get('#increment').realClick()
+ })
+
+ cy.get('button').contains('Save Commands').click()
+ cy.get('#testName').type('new-test')
+ cy.get('button[type=submit]').click()
+
+ cy.location().its('hash').and('not.contain', 'suiteId=').and('not.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.get('a').contains('Cancel').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'] })
+
+ incrementCounter(0)
+
+ cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=')
+
+ // update the spec on the file system by changing the
+ // test name which will cause the save to fail since
+ // the test won't be found
+ cy.withCtx(async (ctx) => {
+ await ctx.actions.file.writeFileInProject('cypress/e2e/spec.cy.js', `
+describe('studio functionality', () => {
+ it('CHANGED - visits a basic html page', () => {
+ cy.visit('cypress/e2e/index.html')
+
+ // new command
+ cy.get('h1').should('have.text', 'Hello, Studio!')
+ })
+})`)
+ })
+
+ cy.get('button').contains('Save Commands').click()
+
+ cy.location().its('hash').should('contain', 'testId=r3').and('contain', 'studio=')
+ })
})
diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts
index 34e7806947..ebdf3d26cf 100644
--- a/packages/app/src/runner/event-manager.ts
+++ b/packages/app/src/runner/event-manager.ts
@@ -315,6 +315,8 @@ export class EventManager {
this.ws.emit('studio:save', saveInfo, (err) => {
if (err) {
this.reporterBus.emit('test:set:state', this.studioStore.saveError(err), noop)
+ } else {
+ this.studioStore.saveSuccess()
}
})
})
@@ -419,7 +421,7 @@ export class EventManager {
const hideCommandLog = Cypress.config('hideCommandLog')
- this.studioStore.initialize(config, runState)
+ this.studioStore.initialize(config)
const runnables = Cypress.runner.normalizeAll(runState.tests, hideCommandLog, testFilter)
@@ -485,14 +487,7 @@ export class EventManager {
return new Bluebird((resolve) => {
this.reporterBus.emit('reporter:collect:run:state', (reporterState: ReporterRunState) => {
- resolve({
- ...reporterState,
- studio: {
- testId: this.studioStore.testId,
- suiteId: this.studioStore.suiteId,
- url: this.studioStore.url,
- },
- })
+ resolve({ reporterState })
})
})
})
@@ -773,14 +768,22 @@ export class EventManager {
* This is also applicable when a user changes their spec file and hot reloads their spec, in which case we need to rebind onMessage
* with the newly creates Cypress.primaryOriginCommunicator
*/
- window?.top?.removeEventListener('message', crossOriginOnMessageRef, false)
- crossOriginOnMessageRef = ({ data, source }) => {
- Cypress?.primaryOriginCommunicator.onMessage({ data, source })
+ try {
+ window.top.removeEventListener('message', crossOriginOnMessageRef, false)
+ crossOriginOnMessageRef = ({ data, source }) => {
+ Cypress?.primaryOriginCommunicator.onMessage({ data, source })
- return undefined
+ return undefined
+ }
+
+ window.top.addEventListener('message', crossOriginOnMessageRef, false)
+ } catch (error) {
+ // in cy-in-cy tests, window.top may not be accessible due to cross-origin restrictions
+ if (error.name !== 'SecurityError') {
+ // re-throw any error that's not a cross-origin error
+ throw error
+ }
}
-
- window.top.addEventListener('message', crossOriginOnMessageRef, false)
}
_runDriver (runState: RunState, testState: CachedTestState) {
diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts
index 89c8d8c589..3df4950325 100644
--- a/packages/app/src/runner/index.ts
+++ b/packages/app/src/runner/index.ts
@@ -318,13 +318,6 @@ async function runSpecE2E (config, spec: SpecFile) {
specSrc: encodeURIComponent(spec.relative),
})
- // FIXME: BILL Determine where to call client with to force browser repaint
- /**
- * call the clientWidth to force the browser to repaint for viewport changes
- * otherwise firefox may fail when changing the viewport in between origins
- * this.refs.container.clientWidth
- */
-
// append to document, so the iframe will execute the spec
addIframe({
$container,
@@ -356,7 +349,7 @@ async function initialize () {
const studioStore = useStudioStore()
- studioStore.cancel()
+ studioStore.reset()
// TODO(lachlan): UNIFY-1318 - use GraphQL to get the viewport dimensions
// once it is more practical to do so
diff --git a/packages/app/src/runner/studio/StudioControls.vue b/packages/app/src/runner/studio/StudioControls.vue
index 311f7efaeb..47a7ce049b 100644
--- a/packages/app/src/runner/studio/StudioControls.vue
+++ b/packages/app/src/runner/studio/StudioControls.vue
@@ -1,5 +1,8 @@
-
+
-
+
@@ -49,6 +56,7 @@