From 3d5a598708b404cc863a1984f67ca52b50c9206f Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Thu, 27 Feb 2025 14:06:32 -0700 Subject: [PATCH 01/14] update url with studio params --- packages/app/src/runner/event-manager.ts | 11 +--- packages/app/src/runner/index.ts | 2 +- packages/app/src/store/studio-store.ts | 72 +++++++++++++++++++----- packages/types/src/driver.ts | 3 +- packages/types/src/reporter.ts | 6 -- 5 files changed, 63 insertions(+), 31 deletions(-) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 34e780694746..d70c16cd04a6 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -419,7 +419,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 +485,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 }) }) }) }) diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index 89c8d8c589c1..d8c6cdd2768f 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -356,7 +356,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/store/studio-store.ts b/packages/app/src/store/studio-store.ts index f5855e8014ed..0eda4e8f50f2 100644 --- a/packages/app/src/store/studio-store.ts +++ b/packages/app/src/store/studio-store.ts @@ -138,11 +138,13 @@ export const useStudioStore = defineStore('studioRecorder', { actions: { setTestId (testId: string) { this.testId = testId + this._updateUrlParams() }, setSuiteId (suiteId: string) { this.suiteId = suiteId this.testId = undefined + this._updateUrlParams() }, clearRunnableIds () { @@ -182,21 +184,15 @@ export const useStudioStore = defineStore('studioRecorder', { this.isFailed = true }, - initialize (config, state) { - const { studio } = state + initialize (config) { + const studio = this._getUrlParams() - if (studio) { - if (studio.testId) { - this.setTestId(studio.testId) - } - - if (studio.suiteId) { - this.setSuiteId(studio.suiteId) - } + if (studio.testId) { + this.setTestId(studio.testId) + } - if (studio.url) { - this.setUrl(studio.url) - } + if (studio.suiteId) { + this.setSuiteId(studio.suiteId) } if (this.testId || this.suiteId) { @@ -271,6 +267,7 @@ export const useStudioStore = defineStore('studioRecorder', { cancel () { this.reset() this.clearRunnableIds() + this._removeUrlParams() }, startSave () { @@ -284,6 +281,7 @@ export const useStudioStore = defineStore('studioRecorder', { save (testName?: string) { this.closeSaveModal() this.stop() + this._removeUrlParams() assertNonNullish(this.absoluteFile, `absoluteFile should exist`) @@ -527,6 +525,54 @@ export const useStudioStore = defineStore('studioRecorder', { return Promise.resolve() }, + _getUrlParams () { + const url = new URL(window.location.href) + const hashParams = new URLSearchParams(url.hash) + + const testId = hashParams.get('testId') + const suiteId = hashParams.get('suiteId') + + return { testId, suiteId } + }, + + _updateUrlParams () { + // if we don't have studio params, we don't need to update them + if (!this.testId && !this.suiteId) return + + // if we have studio params, we need to remove them before adding them back + this._removeUrlParams() + + const url = new URL(window.location.href) + const hashParams = new URLSearchParams(url.hash) + + // set the studio params + hashParams.set('studio', '') + if (this.testId) hashParams.set('testId', this.testId) + + if (this.suiteId) hashParams.set('suiteId', this.suiteId) + + // update the url + url.hash = decodeURIComponent(hashParams.toString()) + window.history.replaceState({}, '', url.toString()) + }, + + _removeUrlParams () { + const url = new URL(window.location.href) + const hashParams = new URLSearchParams(url.hash) + + // if we don't have studio params, we don't need to remove them + if (!hashParams.has('studio')) return + + // remove the studio params + hashParams.delete('studio') + hashParams.delete('testId') + hashParams.delete('suiteId') + + // update the url + url.hash = decodeURIComponent(hashParams.toString()) + window.history.replaceState({}, '', url.toString()) + }, + _trustEvent (event) { // only capture events sent by the actual user // but disable the check if we're in a test diff --git a/packages/types/src/driver.ts b/packages/types/src/driver.ts index fec0163d61b5..62575b931e7e 100644 --- a/packages/types/src/driver.ts +++ b/packages/types/src/driver.ts @@ -1,4 +1,4 @@ -import type { ReporterRunState, StudioRecorderState } from './reporter' +import type { ReporterRunState } from './reporter' interface MochaRunnerState { startTime?: number @@ -12,7 +12,6 @@ interface MochaRunnerState { } export type RunState = MochaRunnerState & ReporterRunState & { - studio?: StudioRecorderState isSpecsListOpen?: boolean } diff --git a/packages/types/src/reporter.ts b/packages/types/src/reporter.ts index 69b06f335fbd..5df729a94556 100644 --- a/packages/types/src/reporter.ts +++ b/packages/types/src/reporter.ts @@ -1,9 +1,3 @@ -export interface StudioRecorderState { - suiteId?: string - testId?: string - url?: string -} - export interface ReporterRunState { autoScrollingEnabled?: boolean scrollTop?: number From 99185d0e715e7171ff71e4a1e8f3673349773ebe Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Fri, 28 Feb 2025 09:05:05 -0700 Subject: [PATCH 02/14] updates --- packages/app/cypress/e2e/studio/helper.ts | 4 +- packages/app/cypress/e2e/studio/studio.cy.ts | 218 +++++++++++++++++- .../app/src/runner/studio/StudioControls.vue | 14 +- 3 files changed, 221 insertions(+), 15 deletions(-) diff --git a/packages/app/cypress/e2e/studio/helper.ts b/packages/app/cypress/e2e/studio/helper.ts index fbc25ac4b420..15bd1aa91322 100644 --- a/packages/app/cypress/e2e/studio/helper.ts +++ b/packages/app/cypress/e2e/studio/helper.ts @@ -13,8 +13,10 @@ export function launchStudio () { cy .contains('visits a basic html page') - .closest('.runnable-wrapper') + .closest('.runnable-wrapper').as('runnable-wrapper') .realHover() + + cy.get('@runnable-wrapper') .findByTestId('launch-studio') .click() diff --git a/packages/app/cypress/e2e/studio/studio.cy.ts b/packages/app/cypress/e2e/studio/studio.cy.ts index f332f231b51d..853d28c2cf8e 100644 --- a/packages/app/cypress/e2e/studio/studio.cy.ts +++ b/packages/app/cypress/e2e/studio/studio.cy.ts @@ -172,7 +172,7 @@ it('visits a basic html page', () => { }) }) - it('creates a test using Studio, but cancels and does not write to file', () => { + it('updates a test but cancels and does not write to file', () => { launchStudio() cy.getAutIframe().within(() => { @@ -196,11 +196,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 }) @@ -225,7 +223,99 @@ it('visits a basic html page', () => { }) }) - // TODO: Can we somehow do the "Create Test" workflow within Cypress in Cypress? + it('removes pending commands when restarting studio', () => { + launchStudio() + + cy.get('[data-cy="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.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=studio-toolbar]').get('button[data-cy=restart-studio]').click() + + cy.waitForSpecToFinish() + + // all of the pending studio commands should have been removed + cy.get('[data-cy="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(` +it('visits a basic html page', () => { + cy.visit('cypress/e2e/index.html') +})`.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.get('[data-cy="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.get('[data-cy="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('creates a brand new test', () => { cy.scaffoldProject('experimental-studio') cy.openProject('experimental-studio') @@ -243,17 +333,44 @@ it('visits a basic html page', () => { 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.get('[data-cy="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', () => { @@ -278,4 +395,81 @@ it('visits a basic html page', () => { .should('be.visible') }) }) + + describe('URL parameters', () => { + it('should update the url with the testId and studio parameters', () => { + launchStudio() + + cy.location().then((location) => { + const hashSearchParams = new URLSearchParams(location.hash) + const testId = hashSearchParams.get('testId') + const studio = hashSearchParams.get('studio') + + expect(testId).to.equal('r2') + expect(studio).to.equal('') + }) + }) + + it('should update the url with the suiteId and studio parameters', () => { + launchStudio() + + cy.location().then((location) => { + const hashSearchParams = new URLSearchParams(location.hash) + const testId = hashSearchParams.get('testId') + const studio = hashSearchParams.get('studio') + + expect(testId).to.equal('r2') + expect(studio).to.equal('') + }) + }) + + it('should remove the studio parameters when saving the test', () => { + launchStudio() + + cy.getAutIframe().within(() => { + cy.get('#increment').realClick() + }) + + cy.get('button').contains('Save Commands').click() + + cy.location().then((location) => { + const hashSearchParams = new URLSearchParams(location.hash) + const testId = hashSearchParams.get('testId') + const studio = hashSearchParams.get('studio') + + expect(testId).to.be.null + expect(studio).to.be.null + }) + }) + + it('should remove the studio parameters when cancelling', () => { + launchStudio() + + cy.get('a').contains('Cancel').click() + + cy.location().then((location) => { + const hashSearchParams = new URLSearchParams(location.hash) + const testId = hashSearchParams.get('testId') + const studio = hashSearchParams.get('studio') + + expect(testId).to.be.null + expect(studio).to.be.null + }) + }) + + it('should remove the studio parameters when navigating away', () => { + launchStudio() + + cy.visit('/') + + cy.location().then((location) => { + const hashSearchParams = new URLSearchParams(location.hash) + const testId = hashSearchParams.get('testId') + const studio = hashSearchParams.get('studio') + + expect(testId).to.be.null + expect(studio).to.be.null + }) + }) + }) }) diff --git a/packages/app/src/runner/studio/StudioControls.vue b/packages/app/src/runner/studio/StudioControls.vue index 311f7efaeb5c..47a7ce049b0e 100644 --- a/packages/app/src/runner/studio/StudioControls.vue +++ b/packages/app/src/runner/studio/StudioControls.vue @@ -1,5 +1,8 @@