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 @@