diff --git a/packages/app/cypress/component/support/ctSupport.ts b/packages/app/cypress/component/support/ctSupport.ts index 8cc2a4393a75..afcb5e02a8f7 100644 --- a/packages/app/cypress/component/support/ctSupport.ts +++ b/packages/app/cypress/component/support/ctSupport.ts @@ -2,8 +2,6 @@ import { AutIframe } from '../../../src/runner/aut-iframe' import { EventManager } from '../../../src/runner/event-manager' import type { Socket } from '@packages/socket/lib/browser' -class StudioRecorderMock {} - export const StubWebsocket = new Proxy(Object.create(null), { get: (obj, prop) => { throw Error(`Cannot access ${String(prop)} on StubWebsocket!`) @@ -29,7 +27,6 @@ export const createEventManager = () => { // @ts-ignore null, // MobX, also not needed in Vue CT tests, null, // selectorPlaygroundModel, - StudioRecorderMock, // needs to be a valid class StubWebsocket, ) } @@ -53,6 +50,5 @@ export const createTestAutIframe = (eventManager = createEventManager()) => { // dom - imports driver which causes problems // so just stubbing it out for now mockDom, - eventManager.studioRecorder, ) } diff --git a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts index c657da01ae69..a3b81edead77 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts @@ -148,7 +148,7 @@ describe('Cypress In Cypress E2E', { viewportWidth: 1500, defaultCommandTimeout: }) it('shows a compilation error with a malformed spec', { viewportHeight: 596, viewportWidth: 1000 }, () => { - const expectedAutHeight = 500 // based on explicitly setting viewport in this test to 596 + const expectedAutHeight = 456 // based on explicitly setting viewport in this test to 596 cy.visitApp() diff --git a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts index d02745f839d6..1d3852eb78e8 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts @@ -243,7 +243,7 @@ describe('Cypress in Cypress', { viewportWidth: 1500, defaultCommandTimeout: 100 cy.visitApp() cy.contains('dom-content.spec').click() - cy.contains('http://localhost:4455/cypress/e2e/dom-content.html').should('be.visible') + cy.findByTestId('aut-url-input').invoke('val').should('contain', 'http://localhost:4455/cypress/e2e/dom-content.html') cy.findByLabelText('Stats').should('not.exist') cy.findByTestId('specs-list-panel').should('not.be.visible') cy.findByTestId('reporter-panel').should('not.be.visible') diff --git a/packages/app/cypress/e2e/reporter_header.cy.ts b/packages/app/cypress/e2e/reporter_header.cy.ts index 3f5186584463..679c9e29b6d7 100644 --- a/packages/app/cypress/e2e/reporter_header.cy.ts +++ b/packages/app/cypress/e2e/reporter_header.cy.ts @@ -19,18 +19,20 @@ describe('Reporter Header', () => { it('filters the list of specs when searching for specs', () => { cy.get('body').type('f') - cy.get('input').type('dom', { force: true }) + cy.findByTestId('specs-list-panel').within(() => { + cy.get('input').as('searchInput').type('dom', { force: true }) + }) cy.get('[data-cy="spec-file-item"]').should('have.length', 3) .should('contain', 'dom-content.spec') - cy.get('input').clear() + cy.get('@searchInput').clear() cy.get('[data-cy="spec-file-item"]').should('have.length', 3) - cy.get('input').type('asdf', { force: true }) + cy.get('@searchInput').type('asdf', { force: true }) - cy.get('[data-cy="spec-file-item"]').should('have.length', 0) + cy.findByTestId('spec-file-item').should('have.length', 0) }) }) diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts index 4404c76e8474..823e76761253 100644 --- a/packages/app/src/main.ts +++ b/packages/app/src/main.ts @@ -4,14 +4,13 @@ import 'virtual:windi.css' import urql from '@urql/vue' import App from './App.vue' import { makeUrqlClient } from '@packages/frontend-shared/src/graphql/urqlClient' -import { decodeBase64Unicode } from '@packages/frontend-shared/src/utils/base64' import { createI18n } from '@cy/i18n' import { createRouter } from './router/router' import { injectBundle } from './runner/injectBundle' import { createPinia } from './store' import Toast, { POSITION } from 'vue-toastification' import 'vue-toastification/dist/index.css' -import { createWebsocket } from './runner' +import { createWebsocket, getRunnerConfigFromWindow } from './runner' // set a global so we can run // conditional code in the vite branch @@ -21,7 +20,7 @@ window.__vite__ = true const app = createApp(App) -const config = JSON.parse(decodeBase64Unicode(window.__CYPRESS_CONFIG__.base64Config)) as Cypress.Config +const config = getRunnerConfigFromWindow() const ws = createWebsocket(config.socketIoRoute) diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx b/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx index a7bd4efc18fa..8682511b6546 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.cy.tsx @@ -125,7 +125,7 @@ describe('SpecRunnerHeaderOpenMode', { viewportHeight: 500 }, () => { }, }) - cy.contains(url).should('exist').should('have.attr', 'href', url) + cy.findByTestId('aut-url-input').invoke('val').should('contain', url) cy.percySnapshot() }) diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue index 3482e870da5c..821e802c576c 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue @@ -23,14 +23,16 @@ > - - {{ autStore.url }} - +
+ + { return autStore.scale < 1 ? `${Math.round(autStore.scale * 100) }%` : 0 }) +const autUrl = computed(() => { + if (studioStore.isActive && studioStore.url) { + return studioStore.url + } + + return autStore.url +}) + const selectorPlaygroundStore = useSelectorPlaygroundStore() const togglePlayground = () => _togglePlayground(autIframe) @@ -220,4 +236,17 @@ const activeSpecPath = specStore.activeSpec?.absolute const isDisabled = computed(() => autStore.isRunning || autStore.isLoading) +function setStudioUrl (event: Event) { + const url = (event.currentTarget as HTMLInputElement).value + + studioStore.setUrl(url) +} + +function openInNewTab () { + if (!autStore.url || studioStore.isActive) { + return + } + + window.open(autStore.url, '_blank')?.focus() +} diff --git a/packages/app/src/runner/StudioControls.vue b/packages/app/src/runner/StudioControls.vue new file mode 100644 index 000000000000..645c55da970d --- /dev/null +++ b/packages/app/src/runner/StudioControls.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/app/src/runner/aut-iframe.ts b/packages/app/src/runner/aut-iframe.ts index d54d4f6bf13d..7a172a9ab3f7 100644 --- a/packages/app/src/runner/aut-iframe.ts +++ b/packages/app/src/runner/aut-iframe.ts @@ -4,6 +4,7 @@ import { logger } from './logger' import _ from 'lodash' /* eslint-disable no-duplicate-imports */ import type { DebouncedFunc } from 'lodash' +import { useStudioStore } from '../store/studio-store' // JQuery bundled w/ Cypress type $CypressJQuery = any @@ -18,7 +19,6 @@ export class AutIframe { private eventManager: any, private $: $CypressJQuery, private dom: any, - private studioRecorder: any, ) { this.debouncedToggleSelectorPlayground = _.debounce(this.toggleSelectorPlayground, 300) } @@ -76,7 +76,7 @@ export class AutIframe { } _body () { - return this._contents()?.find('body') + return this._contents()?.find('body') as unknown as JQuery } detachDom = () => { @@ -481,15 +481,23 @@ export class AutIframe { }) } - startStudio = () => { - if (this.studioRecorder.isLoading) { - this.studioRecorder.start(this._body()?.[0]) - } + startStudio () { + const studioStore = useStudioStore() + + studioStore.start(this._body()?.[0]) } - reattachStudio = () => { - if (this.studioRecorder.isActive) { - this.studioRecorder.attachListeners(this._body()?.[0]) + reattachStudio () { + const studioStore = useStudioStore() + + if (studioStore.isActive) { + const body = this._body()?.[0] + + if (!body) { + throw Error(`Cannot reattach Studio without the HTMLBodyElement for the app`) + } + + studioStore.attachListeners(body) } } } diff --git a/packages/app/src/runner/event-manager-types.ts b/packages/app/src/runner/event-manager-types.ts index 5d77d2a1bfe8..3ea8efdadb94 100644 --- a/packages/app/src/runner/event-manager-types.ts +++ b/packages/app/src/runner/event-manager-types.ts @@ -1,5 +1,6 @@ import type { FileDetails } from '@packages/types' import type { ScriptError } from '../store' +import type { CommandLog, StudioLog } from '../store/studio-store' import type { CypressInCypressMochaEvent } from './event-manager' interface BeforeScreenshot { @@ -20,9 +21,31 @@ export type LocalBusEventMap = { 'open:file': FileDetails } +export interface StudioSavePayload { + fileDetails?: FileDetails + absoluteFile: string + runnableTitle?: string + commands: StudioLog[] + isSuite: boolean + isRoot: boolean + testName?: string +} + export type LocalBusEmitsMap = { + // Local Bus + 'restart': undefined 'open:file': FileDetails 'cypress:in:cypress:run:complete': CypressInCypressMochaEvent[] + + // Studio Events + 'studio:save': StudioSavePayload + 'studio:cancel': undefined + 'studio:copy:to:clipboard': () => void + + // Reporter Events + 'reporter:log:add': CommandLog + 'reporter:log:remove': CommandLog + 'reporter:log:state:changed': CommandLog } export type SocketToDriverMap = { @@ -32,4 +55,5 @@ export type SocketToDriverMap = { export type DriverToLocalBus = { 'visit:blank': { type?: 'session' | 'session-lifecycle' } 'visit:failed': { status?: string, statusText: string, contentType?: () => string } + 'page:loading': boolean } diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index f8170a65df74..2645fdf64ac9 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -11,6 +11,7 @@ import type { Socket } from '@packages/socket/lib/browser' import * as cors from '@packages/network/lib/cors' import { automation, useRunnerUiStore } from '../store' import { useScreenshotStore } from '../store/screenshot-store' +import { useStudioStore } from '../store/studio-store' import { getAutIframeModel } from '.' export type CypressInCypressMochaEvent = Array>> @@ -52,11 +53,11 @@ export class EventManager { reporterBus: EventEmitter = new EventEmitter() localBus: EventEmitter = new EventEmitter() Cypress?: $Cypress - studioRecorder: any selectorPlaygroundModel: any cypressInCypressMochaEvents: CypressInCypressMochaEvent[] = [] // Used for testing the experimentalSingleTabRunMode experiment. Ensures AUT is correctly destroyed between specs. ws: Socket + studioStore: ReturnType constructor ( // import '@packages/driver' @@ -65,13 +66,11 @@ export class EventManager { private Mobx: typeof MobX, // selectorPlaygroundModel singleton selectorPlaygroundModel: any, - // StudioRecorder constructor - StudioRecorderCtor: any, ws: Socket, ) { - this.studioRecorder = new StudioRecorderCtor(this) this.selectorPlaygroundModel = selectorPlaygroundModel this.ws = ws + this.studioStore = useStudioStore() } getCypress () { @@ -150,7 +149,7 @@ export class EventManager { }) this.ws.on('watched:file:changed', () => { - this.studioRecorder.cancel() + this.studioStore.cancel() rerun() }) @@ -283,7 +282,7 @@ export class EventManager { const studioInit = () => { this.ws.emit('studio:init', (showedStudioModal) => { if (!showedStudioModal) { - this.studioRecorder.showInitModal() + this.studioStore.showInitModal() } else { rerun() } @@ -291,28 +290,28 @@ export class EventManager { } this.reporterBus.on('studio:init:test', (testId) => { - this.studioRecorder.setTestId(testId) + this.studioStore.setTestId(testId) studioInit() }) this.reporterBus.on('studio:init:suite', (suiteId) => { - this.studioRecorder.setSuiteId(suiteId) + this.studioStore.setSuiteId(suiteId) studioInit() }) this.reporterBus.on('studio:cancel', () => { - this.studioRecorder.cancel() + this.studioStore.cancel() rerun() }) this.reporterBus.on('studio:remove:command', (commandId) => { - this.studioRecorder.removeLog(commandId) + this.studioStore.removeLog(commandId) }) this.reporterBus.on('studio:save', () => { - this.studioRecorder.startSave() + this.studioStore.startSave() }) this.reporterBus.on('studio:copy:to:clipboard', (cb) => { @@ -320,7 +319,7 @@ export class EventManager { }) this.localBus.on('studio:start', () => { - this.studioRecorder.closeInitModal() + this.studioStore.closeInitModal() rerun() }) @@ -331,13 +330,13 @@ export class EventManager { this.localBus.on('studio:save', (saveInfo) => { this.ws.emit('studio:save', saveInfo, (err) => { if (err) { - this.reporterBus.emit('test:set:state', this.studioRecorder.saveError(err), noop) + this.reporterBus.emit('test:set:state', this.studioStore.saveError(err), noop) } }) }) this.localBus.on('studio:cancel', () => { - this.studioRecorder.cancel() + this.studioStore.cancel() rerun() }) @@ -414,7 +413,7 @@ export class EventManager { return } - this.studioRecorder.initialize(config, state) + this.studioStore.initialize(config, state) const runnables = Cypress.runner.normalizeAll(state.tests) @@ -474,7 +473,11 @@ export class EventManager { this.reporterBus.emit('reporter:collect:run:state', (reporterState) => { resolve({ ...reporterState, - studio: this.studioRecorder.state, + studio: { + testId: this.studioStore.testId, + suiteId: this.studioStore.suiteId, + url: this.studioStore.url, + }, }) }) }) @@ -592,12 +595,12 @@ export class EventManager { }) Cypress.on('test:before:run:async', (_attr, test) => { - this.studioRecorder.interceptTest(test) + this.studioStore.interceptTest(test) }) Cypress.on('test:after:run', (test) => { - if (this.studioRecorder.isOpen && test.state !== 'passed') { - this.studioRecorder.testFailed() + if (this.studioStore.isOpen && test.state !== 'passed') { + this.studioStore.testFailed() } }) @@ -734,6 +737,8 @@ export class EventManager { performance.measure('run', 'run-s', 'run-e') }) + const hasRunnableId = !!this.studioStore.testId || !!this.studioStore.suiteId + this.reporterBus.emit('reporter:start', { startTime: Cypress.runner.getStartTime(), numPassed: state.passed, @@ -742,7 +747,7 @@ export class EventManager { autoScrollingEnabled: state.autoScrollingEnabled, isSpecsListOpen: state.isSpecsListOpen, scrollTop: state.scrollTop, - studioActive: this.studioRecorder.hasRunnableId, + studioActive: hasRunnableId, }) } @@ -770,7 +775,7 @@ export class EventManager { // clean up the cross origin logs in memory to prevent dangling references as the log objects themselves at this point will no longer be needed. crossOriginLogs = {} - this.studioRecorder.setInactive() + this.studioStore.setInactive() } resetReporter () { @@ -796,12 +801,12 @@ export class EventManager { } _interceptStudio (displayProps) { - if (this.studioRecorder.isActive) { - displayProps.hookId = this.studioRecorder.hookId + if (this.studioStore.isActive) { + displayProps.hookId = this.studioStore.hookId if (displayProps.name === 'visit' && displayProps.state === 'failed') { - this.studioRecorder.testFailed() - this.reporterBus.emit('test:set:state', this.studioRecorder.testError, noop) + this.studioStore.testFailed() + this.reporterBus.emit('test:set:state', this.studioStore.testError, noop) } } @@ -809,8 +814,8 @@ export class EventManager { } _studioCopyToClipboard (cb) { - this.ws.emit('studio:get:commands:text', this.studioRecorder.logs, (commandsText) => { - this.studioRecorder.copyToClipboard(commandsText) + this.ws.emit('studio:get:commands:text', this.studioStore.logs, (commandsText) => { + this.studioStore.copyToClipboard(commandsText) .then(cb) }) } diff --git a/packages/app/src/runner/iframe-model.ts b/packages/app/src/runner/iframe-model.ts index a8cdbfabf41c..9bb86a34c379 100644 --- a/packages/app/src/runner/iframe-model.ts +++ b/packages/app/src/runner/iframe-model.ts @@ -2,6 +2,7 @@ import { useSnapshotStore } from './snapshot-store' import { useAutStore } from '../store' import type { EventManager } from './event-manager' import { defaultMessages } from '@cy/i18n' +import { useStudioStore } from '../store/studio-store' export interface AutSnapshot { id?: number @@ -38,7 +39,6 @@ export class IframeModel { private eventManager: EventManager, private studio: { selectorPlaygroundModel: any - recorder: any }, ) { this._reset() @@ -62,6 +62,12 @@ export class IframeModel { const autStore = useAutStore() this.eventManager.on('url:changed', (url: string) => { + const studioStore = useStudioStore() + + if (studioStore.isLoading) { + studioStore.setUrl(url) + } + autStore.updateUrl(url) }) @@ -109,6 +115,7 @@ export class IframeModel { setSnapshots = (snapshotProps: AutSnapshot) => { const snapshotStore = useSnapshotStore() const autStore = useAutStore() + const studioStore = useStudioStore() if (snapshotStore.isSnapshotPinned) { return @@ -118,7 +125,7 @@ export class IframeModel { return snapshotStore.setTestsRunningError() } - if (this.studio.recorder.isOpen) { + if (studioStore.isOpen) { return this._studioOpenError() } diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index ab00b048c82d..fa469ee9fe05 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -59,8 +59,6 @@ export function initializeEventManager (UnifiedRunner: any) { UnifiedRunner.CypressDriver, UnifiedRunner.MobX, UnifiedRunner.selectorPlaygroundModel, - UnifiedRunner.StudioRecorder, - // created once when opening runner at the very top level in main.ts window.ws, ) } @@ -109,7 +107,6 @@ function createIframeModel () { autIframe.doesAUTMatchTopOriginPolicy, getEventManager(), { - recorder: getEventManager().studioRecorder, selectorPlaygroundModel: getEventManager().selectorPlaygroundModel, }, ) @@ -150,7 +147,6 @@ function setupRunner () { getEventManager(), window.UnifiedRunner.CypressJQuery, window.UnifiedRunner.dom, - getEventManager().studioRecorder, ) createIframeModel() @@ -345,7 +341,7 @@ function runSpecE2E (spec: SpecFile) { } export function getRunnerConfigFromWindow () { - return JSON.parse(decodeBase64Unicode(window.__CYPRESS_CONFIG__.base64Config)) + return JSON.parse(decodeBase64Unicode(window.__CYPRESS_CONFIG__.base64Config)) as Cypress.Config } /** diff --git a/packages/app/src/runner/reporter.ts b/packages/app/src/runner/reporter.ts index af35de815fef..e36487a7d9b4 100644 --- a/packages/app/src/runner/reporter.ts +++ b/packages/app/src/runner/reporter.ts @@ -1,6 +1,6 @@ import { getMobxRunnerStore, MobxRunnerStore } from '../store' import { getReporterElement } from './utils' -import { getEventManager } from '.' +import { getEventManager, getRunnerConfigFromWindow } from '.' import type { EventManager } from './event-manager' import { useRunnerUiStore } from '../store/runner-ui-store' @@ -39,14 +39,17 @@ function renderReporter ( ) { const runnerUiStore = useRunnerUiStore() + const config = getRunnerConfigFromWindow() + const reporter = window.UnifiedRunner.React.createElement(window.UnifiedRunner.Reporter, { runMode: 'single' as const, runner: eventManager.reporterBus, autoScrollingEnabled: runnerUiStore.autoScrollingEnabled, isSpecsListOpen: runnerUiStore.isSpecsListOpen, - error: null, // errorMessages.reporterError(props.state.scriptError, props.state.spec.relative), + error: null, resetStatsOnSpecChange: true, - experimentalStudioEnabled: false, + // @ts-ignore - https://github.com/cypress-io/cypress/issues/23338 + studioEnabled: config.experimentalStudio, runnerStore: store, }) diff --git a/packages/app/src/runner/useEventManager.ts b/packages/app/src/runner/useEventManager.ts index 131560488c2c..91c75397b585 100644 --- a/packages/app/src/runner/useEventManager.ts +++ b/packages/app/src/runner/useEventManager.ts @@ -1,6 +1,7 @@ import { watch } from 'vue' import { addCrossOriginIframe, getAutIframeModel, getEventManager, UnifiedRunnerAPI } from '.' import { useAutStore, useSpecStore } from '../store' +import { useStudioStore } from '../store/studio-store' import { empty, getReporterElement, getRunnerElement } from './utils' export function useEventManager () { @@ -8,6 +9,7 @@ export function useEventManager () { const autStore = useAutStore() const specStore = useSpecStore() + const studioStore = useStudioStore() function runSpec (isRerun: boolean = false) { if (!specStore.activeSpec) { @@ -37,10 +39,24 @@ export function useEventManager () { getAutIframeModel().showVisitFailure(payload) }) + eventManager.on('page:loading', (isLoading) => { + if (isLoading) { + return + } + + getAutIframeModel().reattachStudio() + }) + eventManager.on('visit:blank', ({ type }) => { getAutIframeModel().visitBlank({ type }) }) + eventManager.on('run:end', () => { + if (studioStore.isLoading) { + getAutIframeModel().startStudio() + } + }) + eventManager.on('expect:origin', addCrossOriginIframe) } diff --git a/packages/app/src/store/aut-store.ts b/packages/app/src/store/aut-store.ts index 64c5b743e7fa..f8f51d2f13c9 100644 --- a/packages/app/src/store/aut-store.ts +++ b/packages/app/src/store/aut-store.ts @@ -3,7 +3,7 @@ import { defineStore } from 'pinia' export type ScriptError = { type: string, error: string } | null interface AutStoreState { - url?: string + url: string | undefined highlightUrl: boolean viewportWidth: number viewportHeight: number diff --git a/packages/app/src/store/studio-store.ts b/packages/app/src/store/studio-store.ts new file mode 100644 index 000000000000..a7d1c905210d --- /dev/null +++ b/packages/app/src/store/studio-store.ts @@ -0,0 +1,905 @@ +import type { FileDetails, Instrument, TestState } from '@packages/types/src' +import { defineStore } from 'pinia' + +import { getEventManager } from '../runner' +import type { StudioSavePayload } from '../runner/event-manager-types' + +function getCypress () { + const eventManager = getEventManager() + + return eventManager.getCypress() +} + +const saveErrorMessage = (message) => { + return `\ +${message}\n\n\ +Cypress was unable to save these commands to your spec file. \ +You can use the copy button below to copy the commands to your clipboard. \ +\n +Cypress Studio is still in beta and the team is working hard to \ +resolve issues like this. To help us fix this issue more quickly, \ +you can provide us with more information by clicking 'Learn more' below.` +} + +function assertNonNullish ( + value: TValue, + message: string, +): asserts value is NonNullable { + if (value === null || value === undefined) { + throw Error(message) + } +} + +export interface CommandLog { + id: `s${string}` + testId?: string + hookId?: string + state: TestState + name: string + message: string + type: 'parent' | 'child' + number?: number + instrument: Instrument + numElements: number + isStudio: boolean +} + +const eventTypes = [ + 'click', + // 'dblclick', + 'change', + 'keydown', + 'keyup', +] + +const eventsWithValue = [ + 'change', + 'keydown', + 'keyup', +] + +const internalMouseEvents = [ + 'mousedown', + 'mouseover', + 'mouseout', +] + +const tagNamesWithoutText = [ + 'SELECT', + 'INPUT', + 'TEXTAREA', +] + +const tagNamesWithValue = [ + 'BUTTON', + 'INPUT', + 'METER', + 'LI', + 'OPTION', + 'PROGESS', + 'PARAM', + 'TEXTAREA', +] + +export interface StudioLog { + id?: number + name: string + selector?: string + message?: unknown // todo: what is the type + isAssertion?: boolean +} + +interface StudioRecorderState { + initModalIsOpen: boolean + saveModalIsOpen: boolean + logs: StudioLog[] + isLoading: boolean + isActive: boolean + isFailed: boolean + _hasStarted: boolean + + testId?: string + suiteId?: string + url?: string + + fileDetails?: FileDetails + absoluteFile?: string + runnableTitle?: string + _previousMouseEvent?: { + element: Element + selector: string + } + _body?: Element + _currentId: number +} + +export const useStudioStore = defineStore('studioRecorder', { + state: (): StudioRecorderState => { + return { + initModalIsOpen: false, + saveModalIsOpen: false, + logs: [], + url: '', + isLoading: false, + isActive: false, + isFailed: false, + _hasStarted: false, + _currentId: 1, + } + }, + + actions: { + setTestId (testId: string) { + this.testId = testId + }, + + setSuiteId (suiteId: string) { + this.suiteId = suiteId + this.testId = undefined + }, + + clearRunnableIds () { + this.testId = undefined + this.suiteId = undefined + }, + + showInitModal () { + this.initModalIsOpen = true + }, + + closeInitModal () { + this.initModalIsOpen = false + }, + + showSaveModal () { + this.saveModalIsOpen = true + }, + + closeSaveModal () { + this.saveModalIsOpen = false + }, + + startLoading () { + this.isLoading = true + }, + + setInactive () { + this.isActive = false + }, + + setUrl (url?: string) { + this.url = url + }, + + testFailed () { + this.isFailed = true + }, + + initialize (config, state) { + const { studio } = state + + if (studio) { + if (studio.testId) { + this.setTestId(studio.testId) + } + + if (studio.suiteId) { + this.setSuiteId(studio.suiteId) + } + + if (studio.url) { + this.setUrl(studio.url) + } + } + + if (this.testId || this.suiteId) { + this.setAbsoluteFile(config.spec.absolute) + this.startLoading() + + if (this.suiteId) { + getCypress().runner.setOnlySuiteId(this.suiteId) + } else if (this.testId) { + getCypress().runner.setOnlyTestId(this.testId) + } + } + }, + + interceptTest (test) { + if (this.suiteId) { + this.setTestId(test.id) + } + + if (this.testId || this.suiteId) { + if (test.invocationDetails) { + this.setFileDetails(test.invocationDetails) + } + + if (this.suiteId) { + if (test.parent && test.parent.id !== 'r1') { + this.setRunnableTitle(test.parent.title) + } + } else { + this.setRunnableTitle(test.title) + } + } + }, + + start (body) { + this.isActive = true + this.isLoading = false + this.logs = [] + this._currentId = 1 + this._hasStarted = true + + if (this.url) { + this.visitUrl() + } + + this.attachListeners(body) + }, + + stop () { + this.removeListeners() + + this.isActive = false + this.isLoading = false + }, + + reset () { + this.stop() + + this.logs = [] + this.url = undefined + this._hasStarted = false + this._currentId = 1 + this.isFailed = false + }, + + cancel () { + this.reset() + this.clearRunnableIds() + }, + + startSave () { + if (this.suiteId) { + this.showSaveModal() + } else { + this.save() + } + }, + + save (testName?: string) { + this.closeSaveModal() + this.stop() + + assertNonNullish(this.absoluteFile, `absoluteFile should exist`) + + const payload: StudioSavePayload = { + fileDetails: this.fileDetails, + absoluteFile: this.absoluteFile, + runnableTitle: this.runnableTitle, + commands: this.logs, + isSuite: !!this.suiteId, + isRoot: this.suiteId === 'r1', + testName, + } + + getEventManager().emit('studio:save', payload) + }, + + visitUrl (url?: string) { + this.setUrl(url ?? this.url) + + getCypress().cy.visit(this.url) + + this.logs.push({ + id: this._getId(), + selector: undefined, + name: 'visit', + message: this.url, + }) + }, + + _recordEvent (event) { + if (this.isFailed || !this._trustEvent(event)) return + + const $el = window.UnifiedRunner.CypressJQuery(event.target) + + if (this._isAssertionsMenu($el)) { + return + } + + this._closeAssertionsMenu() + + if (!this._shouldRecordEvent(event, $el)) { + return + } + + const name = this._getName(event, $el) + const message = this._getMessage(event, $el) + + if (name === 'change') { + return + } + + let selector: string | undefined = '' + + if (name === 'click' && this._matchPreviousMouseEvent($el)) { + selector = this._previousMouseEvent?.selector + } else { + selector = getCypress().SelectorPlayground.getSelector($el) + } + + this._clearPreviousMouseEvent() + + if (name === 'type' && !message) { + return this._removeLastLogIfType(selector) + } + + const updateOnly = this._updateLastLog(selector, name, message) + + if (updateOnly) { + return + } + + if (name === 'type') { + this._addClearLog(selector) + } + + this._addLog({ + selector, + name, + message, + }) + }, + + _removeLastLogIfType (selector?: string) { + const lastLog = this.logs[this.logs.length - 1] + + if (lastLog.selector === selector && lastLog.name === 'type') { + return this.removeLog(lastLog.id) + } + }, + + removeLog (commandId?: number) { + const index = this.logs.findIndex((command) => command.id === commandId) + const log = this.logs[index] + + this.logs.splice(index, 1) + + this._generateBothLogs(log).forEach((commandLog) => { + getEventManager().emit('reporter:log:remove', commandLog) + }) + }, + + _addLog (log: StudioLog) { + log.id = this._getId() + + this.logs.push(log) + + this._generateBothLogs(log).forEach((commandLog) => { + getEventManager().emit('reporter:log:add', commandLog) + }) + }, + + _addAssertion ($el: HTMLElement, ...args: unknown[]) { + const id = this._getId() + const selector = getCypress().SelectorPlayground.getSelector($el) + + const log: StudioLog = { + id, + selector, + name: 'should', + message: args, + isAssertion: true, + } + + this.logs.push(log) + + const reporterLog = { + id, + selector, + name: 'assert', + message: this._generateAssertionMessage($el, args), + } + + this._generateBothLogs(reporterLog).forEach((commandLog) => { + getEventManager().emit('reporter:log:add', commandLog) + }) + + this._closeAssertionsMenu() + }, + + saveError (err: Error) { + return { + id: this.testId, + err: { + ...err, + message: saveErrorMessage(err.message), + docsUrl: 'https://on.cypress.io/studio-beta', + }, + } + }, + + setFileDetails (fileDetails) { + this.fileDetails = fileDetails + }, + + setAbsoluteFile (absoluteFile: string) { + this.absoluteFile = absoluteFile + }, + + setRunnableTitle (runnableTitle) { + this.runnableTitle = runnableTitle + }, + + _clearPreviousMouseEvent () { + this._previousMouseEvent = undefined + }, + + _matchPreviousMouseEvent (el) { + return this._previousMouseEvent && window.UnifiedRunner.CypressJQuery(el).is(this._previousMouseEvent.element) + }, + + attachListeners (body: HTMLBodyElement) { + if (this.isFailed) { + return + } + + this._body = body + + for (const event of eventTypes) { + this._body.addEventListener(event, this._recordEvent, { + capture: true, + passive: true, + }) + } + + for (const event of internalMouseEvents) { + this._body.addEventListener(event, this._recordMouseEvent, { + capture: true, + passive: true, + }) + } + + this._body.addEventListener('contextmenu', this._openAssertionsMenu, { + capture: true, + }) + + this._clearPreviousMouseEvent() + }, + + removeListeners () { + if (!this._body) return + + for (const event of eventTypes) { + this._body.removeEventListener(event, this._recordEvent, { + capture: true, + }) + } + + for (const event of internalMouseEvents) { + this._body.removeEventListener(event, this._recordMouseEvent, { + capture: true, + }) + } + + this._body.removeEventListener('contextmenu', this._openAssertionsMenu, { + capture: true, + }) + + this._clearPreviousMouseEvent() + }, + + copyToClipboard (commandsText) { + // clipboard API is not supported without secure context + if (window.isSecureContext && navigator.clipboard) { + return navigator.clipboard.writeText(commandsText) + } + + // fallback to creating invisible textarea + // create the textarea in our document rather than this._body + // as to not interfere with the app in the aut + const textArea = document.createElement('textarea') + + textArea.value = commandsText + textArea.style.position = 'fixed' + textArea.style.opacity = '0' + + document.body.appendChild(textArea) + textArea.select() + document.execCommand('copy') + textArea.remove() + + return Promise.resolve() + }, + + _trustEvent (event) { + // only capture events sent by the actual user + // but disable the check if we're in a test + return event.isTrusted || getCypress().env('INTERNAL_E2E_TESTS') === 1 + }, + + _recordMouseEvent (event) { + if (!this._trustEvent(event)) return + + const { type, target } = event + + if (type === 'mouseout') { + return this._clearPreviousMouseEvent() + } + + // we only replace the previous mouse event if the element is different + // since we want to use the oldest possible selector + if (!this._matchPreviousMouseEvent(target)) { + this._previousMouseEvent = { + element: target, + selector: getCypress().SelectorPlayground.getSelector(window.UnifiedRunner.CypressJQuery(target)), + } + } + }, + + _getId () { + return this._currentId++ + }, + + _getName (event, $el) { + const tagName = $el.prop('tagName') + const { type } = event + + if (tagName === 'SELECT' && type === 'change') { + return 'select' + } + + if (type === 'keydown' || type === 'keyup') { + return 'type' + } + + if (type === 'click' && tagName === 'INPUT') { + const inputType = $el.prop('type') + const checked = $el.prop('checked') + + if (inputType === 'radio' || (inputType === 'checkbox' && checked)) { + return 'check' + } + + if (inputType === 'checkbox') { + return 'uncheck' + } + } + + return type + }, + + _getMessage (event, $el) { + if (!eventsWithValue.includes(event.type)) { + return undefined + } + + let val = $el.val() + + if (event.type === 'keydown' || event.type === 'keyup') { + val = val.replace(/{/g, '{{}') + + if (event.key === 'Enter') { + val = `${val}{enter}` + } + } + + return val + }, + + _shouldRecordEvent (event, $el) { + const tagName = $el.prop('tagName') + + // only want to record keystrokes within input elements + if ((event.type === 'keydown' || event.type === 'keyup') && tagName !== 'INPUT') { + return false + } + + // we record all normal keys on keyup (rather than keydown) since the input value will be updated + // we do not record enter on keyup since a form submission will have already been triggered + if (event.type === 'keyup' && event.key === 'Enter') { + return false + } + + // we record enter on keydown since this happens before a form submission is triggered + // all other keys are recorded on keyup + if (event.type === 'keydown' && event.key !== 'Enter') { + return false + } + + // cy cannot click on a select + if (tagName === 'SELECT' && event.type === 'click') { + return false + } + + // do not record clicks on option elements since this is handled with cy.select() + if (tagName === 'OPTION') { + return false + } + + return true + }, + + _generateLog ({ id, name, message, type, number }: { id: `s${string}`, name: string, message: unknown, type: 'parent' | 'child', number?: number }): CommandLog { + return { + id, + testId: this.testId, + hookId: this.hookId, + name, + message: 'message!', // message ? $driverUtils.stringifyActual(message) : undefined, + type, + state: 'passed', + instrument: 'command', + number, + numElements: 1, + isStudio: true, + } + }, + + _generateBothLogs (log): [CommandLog, CommandLog] { + return [ + this._generateLog({ + id: `s${log.id}-get`, + name: 'get', + message: log.selector, + type: 'parent', + number: log.id, + }), + this._generateLog({ + id: `s${log.id}`, + name: log.name, + message: log.message, + type: 'child', + }), + ] + }, + + _addClearLog (selector) { + const lastLog = this.logs[this.logs.length - 1] + + if (lastLog && lastLog.name === 'clear' && lastLog.selector === selector) { + return + } + + this._addLog({ + selector, + name: 'clear', + message: undefined, + }) + }, + + _updateLog (log: StudioLog) { + const { id, name, message } = log + + getEventManager().emit('reporter:log:state:changed', this._generateLog({ + id: `s${id}`, + name, + message, + type: 'child', + })) + }, + + _updateLastLog (selector: string | undefined, name: string, message: unknown) { + const { length } = this.logs + + if (!length) { + return false + } + + const lastLog = this.logs[length - 1] + + const updateLog = (newName = name, newMessage = message) => { + lastLog.message = newMessage + lastLog.name = newName + + this._updateLog(lastLog) + } + + if (selector === lastLog.selector) { + if (name === 'type' && lastLog.name === 'type') { + updateLog() + + return true + } + + // Cypress automatically issues a .click before every type + // so we can turn the extra click event into the .clear that comes before every type + if (name === 'type' && lastLog.name === 'click') { + updateLog('clear', undefined) + + // we return false since we still need to add the type log + return false + } + } + + return false + }, + + _generateAssertionMessage ($el: HTMLElement, ...args: any[]) { + const elementString = $el.tagName // $driverUtils.stringifyActual($el) + const assertionString = args[0].replace(/\./g, ' ') + + let message = `expect **${elementString}** to ${assertionString}` + + if (args[1]) { + message = `${message} **${args[1]}**` + } + + if (args[2]) { + message = `${message} with the value **${args[2]}**` + } + + return message + }, + + _isAssertionsMenu ($el) { + return $el.hasClass('__cypress-studio-assertions-menu') + }, + + _openAssertionsMenu (event) { + if (!this._body) { + throw Error('this._body was not defined') + } + + event.preventDefault() + event.stopPropagation() + + const $el = window.UnifiedRunner.CypressJQuery(event.target) + + if (this._isAssertionsMenu($el)) { + return + } + + this._closeAssertionsMenu() + + window.UnifiedRunner.dom.openStudioAssertionsMenu({ + $el, + $body: window.UnifiedRunner.CypressJQuery(this._body), + props: { + possibleAssertions: this._generatePossibleAssertions($el), + addAssertion: this._addAssertion, + closeMenu: this._closeAssertionsMenu, + }, + }) + }, + + _closeAssertionsMenu () { + if (!this._body) { + throw Error('this._body was not defined') + } + + window.UnifiedRunner.dom.closeStudioAssertionsMenu(window.UnifiedRunner.CypressJQuery(this._body)) + }, + + _generatePossibleAssertions ($el: JQuery) { + const tagName = $el.prop('tagName') + + const possibleAssertions: Array<{ type: string, options?: unknown[] }> = [] + + if (!tagNamesWithoutText.includes(tagName)) { + const text = $el.text() + + if (text) { + possibleAssertions.push({ + type: 'have.text', + options: [{ + value: text, + }], + }) + } + } + + if (tagNamesWithValue.includes(tagName)) { + const val = $el.val() + + if (val !== undefined && val !== '') { + possibleAssertions.push({ + type: 'have.value', + options: [{ + value: val, + }], + }) + } + } + + const attributes = Array.from($el[0].attributes).reduce>((acc, { name, value }) => { + if (name === 'value' || name === 'disabled') { + return acc + } + + if (name === 'class') { + possibleAssertions.push({ + type: 'have.class', + options: value.split(' ').map((value) => ({ value })), + }) + + return acc + } + + if (name === 'id') { + possibleAssertions.push({ + type: 'have.id', + options: [{ + value, + }], + }) + + return acc + } + + if (name !== undefined && name !== '' && value !== undefined && value !== '') { + return acc.concat({ + name, + value, + }) + } + + return acc + }, []) + + if (attributes.length > 0) { + possibleAssertions.push({ + type: 'have.attr', + options: attributes, + }) + } + + possibleAssertions.push({ + type: 'be.visible', + }) + + const isDisabled = $el.prop('disabled') + + if (isDisabled !== undefined) { + possibleAssertions.push({ + type: isDisabled ? 'be.disabled' : 'be.enabled', + }) + } + + const isChecked = $el.prop('checked') + + if (isChecked !== undefined) { + possibleAssertions.push({ + type: isChecked ? 'be.checked' : 'not.be.checked', + }) + } + + return possibleAssertions + }, + }, + + getters: { + hasRunnableId (state) { + return !!state.testId || !!state.suiteId + }, + + isOpen: (state) => { + return state.isActive || state.isLoading || state._hasStarted + }, + + isEmpty: (state): boolean => { + return state.logs.length === 0 + }, + + isReady (state): boolean { + return this.isOpen && this.isEmpty && !state.isLoading && !state.isFailed + }, + + hookId: (state) => { + return `${state.testId}-studio` + }, + + needsUrl: (state) => { + return state.isActive && !state.url && !state.isFailed + }, + + testError: (state) => { + return { + id: state.testId, + state: 'failed', + } + }, + }, +}) diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index f8433dda93a2..e80dcbb2fccf 100644 --- a/packages/config/__snapshots__/index.spec.ts.js +++ b/packages/config/__snapshots__/index.spec.ts.js @@ -7,7 +7,6 @@ exports['config/src/index .getBreakingKeys returns list of breaking config keys "experimentalRunEvents", "experimentalSessionSupport", "experimentalShadowDomSupport", - "experimentalStudio", "firefoxGcInterval", "ignoreTestFiles", "integrationFolder", diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 31b629ea08cb..cde15631bb3b 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -24,7 +24,6 @@ const BREAKING_OPTION_ERROR_KEY: Readonly = [ 'EXPERIMENTAL_SINGLE_TAB_RUN_MODE', 'EXPERIMENTAL_SHADOW_DOM_REMOVED', 'EXPERIMENTAL_STUDIO_REMOVED', - 'EXPERIMENTAL_STUDIO_REMOVED', 'FIREFOX_GC_INTERVAL_REMOVED', 'NODE_VERSION_DEPRECATION_SYSTEM', 'NODE_VERSION_DEPRECATION_BUNDLED', @@ -566,11 +565,6 @@ export const breakingOptions: Readonly = [ name: 'experimentalShadowDomSupport', errorKey: 'EXPERIMENTAL_SHADOW_DOM_REMOVED', isWarning: true, - }, { - name: 'experimentalStudio', - errorKey: 'EXPERIMENTAL_STUDIO_REMOVED', - isWarning: true, - showInLaunchpad: true, }, { name: 'firefoxGcInterval', errorKey: 'FIREFOX_GC_INTERVAL_REMOVED', @@ -622,11 +616,6 @@ export const breakingRootOptions: Array = [ errorKey: 'CONFIG_FILE_INVALID_ROOT_CONFIG', isWarning: false, testingTypes: ['component', 'e2e'], - }, { - name: 'experimentalStudio', - errorKey: 'EXPERIMENTAL_STUDIO_REMOVED', - isWarning: true, - testingTypes: ['component', 'e2e'], }, { name: 'indexHtmlFile', errorKey: 'CONFIG_FILE_INVALID_ROOT_CONFIG_COMPONENT', diff --git a/packages/config/test/index.spec.ts b/packages/config/test/index.spec.ts index 4007a4bdeb88..0a6ded9fda8b 100644 --- a/packages/config/test/index.spec.ts +++ b/packages/config/test/index.spec.ts @@ -176,28 +176,6 @@ describe('config/src/index', () => { }) }) - describe('.validateNoBreakingConfigLaunchpad', () => { - it('calls warning callback if config contains breaking option that should be shown in launchpad', () => { - const warningFn = sinon.spy() - const errorFn = sinon.spy() - - configUtil.validateNoBreakingConfigLaunchpad({ - 'experimentalStudio': 'should break', - configFile: 'config.js', - }, warningFn, errorFn) - - expect(warningFn).to.have.been.calledOnceWith('EXPERIMENTAL_STUDIO_REMOVED', { - name: 'experimentalStudio', - newName: undefined, - value: undefined, - testingType: undefined, - configFile: 'config.js', - }) - - expect(errorFn).to.have.callCount(0) - }) - }) - describe('.validateOverridableAtRunTime', () => { it('calls onError handler if configuration override level=never', () => { const errorFn = sinon.spy() diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index 3b8fab921591..c51cbf7e2ffd 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -988,16 +988,6 @@ describe('config/src/project/utils', () => { expect(warning).to.be.calledWith('EXPERIMENTAL_RUN_EVENTS_REMOVED') }) - it('warns if experimentalStudio is passed', async function () { - const warning = sinon.spy(errors, 'warning') - - await this.defaults('experimentalStudio', true, { - experimentalStudio: true, - }) - - expect(warning).to.be.calledWith('EXPERIMENTAL_STUDIO_REMOVED') - }) - // @see https://github.com/cypress-io/cypress/pull/9185 it('warns if experimentalNetworkStubbing is passed', async function () { const warning = sinon.spy(errors, 'warning') diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index ffacf3c4d442..1340a1e60793 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'cypress' export default defineConfig({ 'projectId': 'ypt4pf', + // @ts-ignore - https://github.com/cypress-io/cypress/issues/23338 + 'experimentalStudio': true, 'hosts': { '*.foobar.com': '127.0.0.1', '*.idp.com': '127.0.0.1', diff --git a/packages/launchpad/cypress/e2e/config-warning.cy.ts b/packages/launchpad/cypress/e2e/config-warning.cy.ts index 222983059b69..779ef2a6027a 100644 --- a/packages/launchpad/cypress/e2e/config-warning.cy.ts +++ b/packages/launchpad/cypress/e2e/config-warning.cy.ts @@ -87,7 +87,9 @@ describe('experimentalSingleTabRunMode', () => { }) }) -describe('experimentalStudio', () => { +// TODO: Figure out this. experimentalStudio is back, but must be nested under E2E? Or, does +// a top level experimentalStudio get applied, but only to E2E? +describe.skip('experimentalStudio', () => { it('should show experimentalStudio warning if Cypress detects experimentalStudio config has been set', () => { cy.scaffoldProject('experimental-studio') cy.openProject('experimental-studio') diff --git a/packages/reporter/cypress/e2e/commands.cy.ts b/packages/reporter/cypress/e2e/commands.cy.ts index f9caae2c3f81..a54a8eb69d60 100644 --- a/packages/reporter/cypress/e2e/commands.cy.ts +++ b/packages/reporter/cypress/e2e/commands.cy.ts @@ -958,7 +958,7 @@ describe('commands', { viewportHeight: 1000 }, () => { }) // FIXME: When studio support is re-introduced we can enable these tests. - context.skip('studio commands', () => { + context('studio commands', () => { beforeEach(() => { addCommand(runner, { id: 10, @@ -990,10 +990,10 @@ describe('commands', { viewportHeight: 1000 }, () => { it('only parent studio commands display remove button', () => { cy.contains('#studio-command-parent').closest('.command') - .find('.studio-command-remove').should('be.visible') + .find('.studio-command-remove').should('exist') cy.contains('#studio-command-child').closest('.command') - .find('.studio-command-remove').should('not.be.visible') + .find('.studio-command-remove').should('not.exist') }) it('emits studio:remove:command with number when delete button is clicked', () => { diff --git a/packages/reporter/cypress/e2e/runnables.cy.ts b/packages/reporter/cypress/e2e/runnables.cy.ts index 7f16699c71b0..b7768f366104 100644 --- a/packages/reporter/cypress/e2e/runnables.cy.ts +++ b/packages/reporter/cypress/e2e/runnables.cy.ts @@ -24,6 +24,7 @@ describe('runnables', () => { cy.visit('/').then((win) => { win.render(Object.assign({ runner, + studioEnabled: true, runnerStore: { spec: { name: 'foo', @@ -140,7 +141,7 @@ describe('runnables', () => { }) it('displays error', () => { - start() + start({ studioEnabled: false }) cy.contains('No tests found.').should('be.visible') cy.contains('Cypress could not detect tests in this file.').should('be.visible') @@ -170,8 +171,7 @@ describe('runnables', () => { cy.get('.help-link').should('have.attr', 'target', '_blank') }) - // FIXME: When studio support is re-introduced we can enable these tests. - it.skip('can launch studio', () => { + it('can launch studio', () => { start().then(() => { cy.stub(runner, 'emit') diff --git a/packages/reporter/cypress/e2e/suites.cy.ts b/packages/reporter/cypress/e2e/suites.cy.ts index 663172d1d335..ccb4183eb359 100644 --- a/packages/reporter/cypress/e2e/suites.cy.ts +++ b/packages/reporter/cypress/e2e/suites.cy.ts @@ -15,6 +15,7 @@ describe('suites', () => { cy.visit('/').then((win) => { win.render({ runner, + studioEnabled: true, runnerStore: { spec: { name: 'foo.js', @@ -129,8 +130,7 @@ describe('suites', () => { }) }) - // FIXME: When studio support is re-introduced we can enable these tests. - describe.skip('studio button', () => { + describe('studio button', () => { it('displays studio icon with half transparency when hovering over test title', () => { cy.contains('nested suite 1') .closest('.runnable-wrapper') diff --git a/packages/reporter/cypress/e2e/tests.cy.ts b/packages/reporter/cypress/e2e/tests.cy.ts index 98a95b8d4e1e..c7d4f57b0cce 100644 --- a/packages/reporter/cypress/e2e/tests.cy.ts +++ b/packages/reporter/cypress/e2e/tests.cy.ts @@ -2,44 +2,39 @@ import { EventEmitter } from 'events' import { RootRunnable } from '../../src/runnables/runnables-store' import { addCommand } from '../support/utils' -describe('tests', () => { - let runner: EventEmitter - let runnables: RootRunnable - - const addStudioCommand = () => { - addCommand(runner, { - hookId: 'r3-studio', - name: 'get', - message: '#studio-command', - state: 'success', - isStudio: true, - }) - } +let runner: EventEmitter +let runnables: RootRunnable - beforeEach(() => { - cy.fixture('runnables').then((_runnables) => { - runnables = _runnables - }) +function visitAndRenderReporter () { + cy.fixture('runnables').then((_runnables) => { + runnables = _runnables + }) - runner = new EventEmitter() - - cy.visit('/').then((win) => { - win.render({ - runner, - runnerStore: { - spec: { - name: 'foo.js', - relative: 'relative/path/to/foo.js', - absolute: '/absolute/path/to/foo.js', - }, + runner = new EventEmitter() + + cy.visit('/').then((win) => { + win.render({ + studioEnabled: true, + runner, + runnerStore: { + spec: { + name: 'foo.js', + relative: 'relative/path/to/foo.js', + absolute: '/absolute/path/to/foo.js', }, - }) + }, }) + }) - cy.get('.reporter').then(() => { - runner.emit('runnables:ready', runnables) - runner.emit('reporter:start', {}) - }) + cy.get('.reporter').then(() => { + runner.emit('runnables:ready', runnables) + runner.emit('reporter:start', { studioActive: true }) + }) +} + +describe('tests', () => { + beforeEach(() => { + visitAndRenderReporter() }) it('includes the class "test"', () => { @@ -132,177 +127,185 @@ describe('tests', () => { .find('.collapsible-content').should('not.exist') }) }) +}) - // FIXME: When studio support is re-introduced we can enable these tests. - describe.skip('studio', () => { - describe('button', () => { - it('displays studio icon with half transparency when hovering over test title', { scrollBehavior: false }, () => { - cy.contains('test 1') - .closest('.runnable-wrapper') - .realHover() - .find('.runnable-controls-studio') - .should('be.visible') - .should('have.css', 'opacity', '0.5') - }) +describe('studio controls', () => { + beforeEach(() => { + visitAndRenderReporter() + cy.contains('test 1').click() + .parents('.collapsible').first() + .find('.studio-controls').as('studioControls') + }) - it('displays studio icon with no transparency and tooltip on hover', { scrollBehavior: false }, () => { - cy.contains('test 1') - .closest('.collapsible-header') - .find('.runnable-controls-studio') - .realHover() - .should('be.visible') - .should('have.css', 'opacity', '1') + const addStudioCommand = () => { + addCommand(runner, { + hookId: 'r3-studio', + name: 'get', + message: '#studio-command', + state: 'success', + isStudio: true, + }) + } - cy.get('.cy-tooltip').contains('Add Commands to Test') - }) + describe('copy button', () => { + it('is disabled without tooltip when there are no commands', () => { + cy.get('@studioControls') + .find('.studio-copy') + .should('be.disabled') + .parent('span') + .trigger('mouseover') - it('emits studio:init:test with the suite id when studio button clicked', () => { - cy.stub(runner, 'emit') + cy.get('.cy-tooltip').should('not.exist') + }) - cy.contains('test 1').parents('.collapsible-header') - .find('.runnable-controls-studio').click() + it('is enabled with tooltip when there are commands', () => { + addStudioCommand() - cy.wrap(runner.emit).should('be.calledWith', 'studio:init:test', 'r3') - }) + cy.get('@studioControls') + .find('.studio-copy') + .should('not.be.disabled') + .trigger('mouseover') + + cy.get('.cy-tooltip').should('have.text', 'Copy Commands to Clipboard') }) - describe('controls', () => { - it('is not visible by default', () => { - cy.contains('test 1').click() - .parents('.collapsible').first() - .find('.studio-controls').should('not.exist') - }) + it('is emits studio:copy:to:clipboard when clicked', () => { + addStudioCommand() - describe('with studio active', () => { - beforeEach(() => { - runner.emit('reporter:start', { studioActive: true }) + cy.stub(runner, 'emit') - cy.contains('test 1').click() - .parents('.collapsible').first() - .find('.studio-controls').as('studioControls') - }) + cy.get('@studioControls').find('.studio-copy').click() - it('is visible with save and copy button when test passed', () => { - cy.get('@studioControls').should('be.visible') - cy.get('@studioControls').find('.studio-save').should('be.visible') - cy.get('@studioControls').find('.studio-copy').should('be.visible') + cy.wrap(runner.emit).should('be.calledWith', 'studio:copy:to:clipboard') + }) - cy.percySnapshot() - }) + it('displays success state after commands are copied', () => { + addStudioCommand() - it('is visible without save and copy button if test failed', () => { - cy.contains('test 2') - .parents('.collapsible').first() - .find('.studio-controls').should('be.visible') + cy.stub(runner, 'emit').callsFake((event, callback) => { + if (event === 'studio:copy:to:clipboard') { + callback('') + } + }) - cy.contains('test 2') - .parents('.collapsible').first() - .find('.studio-save').should('not.be.visible') + cy.get('@studioControls') + .find('.studio-copy') + .click() + .should('have.class', 'studio-copy-success') + .trigger('mouseover') - cy.contains('test 2') - .parents('.collapsible').first() - .find('.studio-copy').should('not.be.visible') - }) + cy.get('.cy-tooltip').should('have.text', 'Commands Copied!') + }) + }) - it('is visible without save and copy button if test was skipped', () => { - cy.contains('nested suite 1') - .parents('.collapsible').first() - .contains('test 1').click() - .parents('.collapsible').first() - .find('.studio-controls').as('pendingControls') - .should('be.visible') + describe('button', () => { + it('displays studio icon with half transparency when hovering over test title', { scrollBehavior: false }, () => { + cy.contains('test 1') + .closest('.runnable-wrapper') + .realHover() + .find('.runnable-controls-studio') + .should('be.visible') + .should('have.css', 'opacity', '0.5') + }) - cy.get('@pendingControls').find('.studio-save').should('not.be.visible') - cy.get('@pendingControls').find('.studio-copy').should('not.be.visible') - }) + it('displays studio icon with no transparency and tooltip on hover', { scrollBehavior: false }, () => { + cy.contains('test 1') + .closest('.collapsible-header') + .find('.runnable-controls-studio') + .realHover() + .should('be.visible') + .should('have.css', 'opacity', '1') - it('is not visible while test is running', () => { - cy.contains('nested suite 1') - .parents('.collapsible').first() - .contains('test 2').click() - .parents('.collapsible').first() - .find('.studio-controls').should('not.be.visible') - }) + cy.get('.cy-tooltip').contains('Add Commands to Test') + }) - it('emits studio:cancel when cancel button clicked', () => { - cy.stub(runner, 'emit') + it('emits studio:init:test with the suite id when studio button clicked', () => { + cy.stub(runner, 'emit') - cy.get('@studioControls').find('.studio-cancel').click() + cy.contains('test 1').parents('.collapsible-header') + .find('.runnable-controls-studio').click() - cy.wrap(runner.emit).should('be.calledWith', 'studio:cancel') - }) + cy.wrap(runner.emit).should('be.calledWith', 'studio:init:test', 'r3') + }) + }) - describe('copy button', () => { - it('is disabled without tooltip when there are no commands', () => { - cy.get('@studioControls') - .find('.studio-copy') - .should('be.disabled') - .parent('span') - .trigger('mouseover') + describe('controls', () => { + it('is not visible by default', () => { + cy.contains('test 1').click() + .parents('.collapsible').first() + .find('.studio-controls').should('not.exist') + }) - cy.get('.cy-tooltip').should('not.exist') - }) + describe('with studio active', () => { + it('is visible with save and copy button when test passed', () => { + cy.get('@studioControls').should('be.visible') + cy.get('@studioControls').find('.studio-save').should('be.visible') + cy.get('@studioControls').find('.studio-copy').should('be.visible') - it('is enabled with tooltip when there are commands', () => { - addStudioCommand() + cy.percySnapshot() + }) - cy.get('@studioControls') - .find('.studio-copy') - .should('not.be.disabled') - .trigger('mouseover') + it('is visible without save and copy button if test failed', () => { + cy.contains('test 2') + .parents('.collapsible').first() + .find('.studio-controls').should('be.visible') - cy.get('.cy-tooltip').should('have.text', 'Copy Commands to Clipboard') - }) + cy.contains('test 2') + .parents('.collapsible').first() + .find('.studio-save').should('not.be.visible') - it('is emits studio:copy:to:clipboard when clicked', () => { - addStudioCommand() + cy.contains('test 2') + .parents('.collapsible').first() + .find('.studio-copy').should('not.be.visible') + }) - cy.stub(runner, 'emit') + it('is visible without save and copy button if test was skipped', () => { + cy.contains('nested suite 1') + .parents('.collapsible').first() + .contains('test 1').click() + .parents('.collapsible').first() + .find('.studio-controls').as('pendingControls') + .should('be.visible') - cy.get('@studioControls').find('.studio-copy').click() + cy.get('@pendingControls').find('.studio-save').should('not.be.visible') + cy.get('@pendingControls').find('.studio-copy').should('not.be.visible') + }) - cy.wrap(runner.emit).should('be.calledWith', 'studio:copy:to:clipboard') - }) + it('is not visible while test is running', () => { + cy.contains('nested suite 1') + .parents('.collapsible').first() + .contains('test 2').click() + .parents('.collapsible').first() + .find('.studio-controls').should('not.be.visible') + }) - it('displays success state after commands are copied', () => { - addStudioCommand() + it('emits studio:cancel when cancel button clicked', () => { + cy.stub(runner, 'emit') - cy.stub(runner, 'emit').callsFake((event, callback) => { - if (event === 'studio:copy:to:clipboard') { - callback('') - } - }) + cy.get('@studioControls').find('.studio-cancel').click() - cy.get('@studioControls') - .find('.studio-copy') - .click() - .should('have.class', 'studio-copy-success') - .trigger('mouseover') + cy.wrap(runner.emit).should('be.calledWith', 'studio:cancel') + }) - cy.get('.cy-tooltip').should('have.text', 'Commands Copied!') - }) + describe('save button', () => { + it('is disabled without commands', () => { + cy.get('@studioControls').find('.studio-save').should('be.disabled') }) - describe('save button', () => { - it('is disabled without commands', () => { - cy.get('@studioControls').find('.studio-save').should('be.disabled') - }) - - it('is enabled when there are commands', () => { - addStudioCommand() + it('is enabled when there are commands', () => { + addStudioCommand() - cy.get('@studioControls').find('.studio-save').should('not.be.disabled') - }) + cy.get('@studioControls').find('.studio-save').should('not.be.disabled') + }) - it('is emits studio:save when clicked', () => { - addStudioCommand() + it('is emits studio:save when clicked', () => { + addStudioCommand() - cy.stub(runner, 'emit') + cy.stub(runner, 'emit') - cy.get('@studioControls').find('.studio-save').click() + cy.get('@studioControls').find('.studio-save').click() - cy.wrap(runner.emit).should('be.calledWith', 'studio:save') - }) + cy.wrap(runner.emit).should('be.calledWith', 'studio:save') }) }) }) diff --git a/packages/reporter/package.json b/packages/reporter/package.json index 7c25a0e9386b..9f990b666ca6 100644 --- a/packages/reporter/package.json +++ b/packages/reporter/package.json @@ -17,6 +17,7 @@ "@fontsource/open-sans": "4.3.0", "@fortawesome/fontawesome-free": "6.0.0", "@packages/driver": "0.0.0-development", + "@packages/types": "0.0.0-development", "@packages/web-config": "0.0.0-development", "@reach/dialog": "0.10.5", "classnames": "2.3.1", diff --git a/packages/reporter/src/attempts/attempts.tsx b/packages/reporter/src/attempts/attempts.tsx index 1bd765ec6398..a21207dc276b 100644 --- a/packages/reporter/src/attempts/attempts.tsx +++ b/packages/reporter/src/attempts/attempts.tsx @@ -2,12 +2,13 @@ import cs from 'classnames' import { observer } from 'mobx-react' import React, { Component } from 'react' +import { TestState } from '@packages/types' import Agents from '../agents/agents' import Collapsible from '../collapsible/collapsible' import Hooks from '../hooks/hooks' import Routes from '../routes/routes' import TestError from '../errors/test-error' -import TestModel, { TestState } from '../test/test-model' +import TestModel from '../test/test-model' import AttemptModel from './attempt-model' import Sessions from '../sessions/sessions' diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index 6366eb2bdb74..1ecdc7890d6d 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -299,7 +299,11 @@ class Command extends Component { } return ( -
  • +
  • { } + {model.type === 'parent' && model.isStudio && ( + + )} {isSessionCommand && ( { }, 50) } } + + _removeStudioCommand = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + const { model, events } = this.props + + if (!model.isStudio) { + return + } + + events.emit('studio:remove:command', model.number) + } } export { Aliases, AliasesReferences, Message, Progress } diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 8860244692e0..1da5f6e9a8d7 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -21,6 +21,19 @@ font-family: $monospace; } + .command-is-studio { + cursor: auto; + + &.command-type-parent .commands-controls .studio-command-remove { + display: block; + padding-left: 5px; + + &:hover { + color: #565554; + } + } + } + // System Command Styles .command-type-system { user-select: none; diff --git a/packages/reporter/src/errors/errors.scss b/packages/reporter/src/errors/errors.scss index 2184e69d41f6..8402ddc4938b 100644 --- a/packages/reporter/src/errors/errors.scss +++ b/packages/reporter/src/errors/errors.scss @@ -59,6 +59,14 @@ $code-border-radius: 4px; text-align: center; } + .studio-err-wrapper { + display: none; + } + + &.studio-active .attempt-failed .studio-err-wrapper { + display: block; + } + .runnable-err { border-left: 2px solid $fail; background-color: $err-background; diff --git a/packages/reporter/src/instruments/instrument-model.ts b/packages/reporter/src/instruments/instrument-model.ts index b21858909c99..4431dd0f6f2c 100644 --- a/packages/reporter/src/instruments/instrument-model.ts +++ b/packages/reporter/src/instruments/instrument-model.ts @@ -1,5 +1,5 @@ import { observable } from 'mobx' -import { TestState } from '../test/test-model' +import { Instrument, TestState } from '@packages/types' export interface AliasObject { name: string @@ -22,7 +22,7 @@ export interface InstrumentProps { testCurrentRetry?: number state: TestState referencesAlias?: Alias - instrument?: 'agent' | 'command' | 'route' + instrument?: Instrument testId: string } diff --git a/packages/reporter/src/lib/state-icon.tsx b/packages/reporter/src/lib/state-icon.tsx index eb21c497039f..915226f10898 100644 --- a/packages/reporter/src/lib/state-icon.tsx +++ b/packages/reporter/src/lib/state-icon.tsx @@ -2,8 +2,7 @@ import cs from 'classnames' import { observer } from 'mobx-react' import React from 'react' -import { TestState } from '../test/test-model' - +import { TestState } from '@packages/types' import FailedIcon from '-!react-svg-loader!@packages/frontend-shared/src/assets/icons/status-failed_x12.svg' import PassedIcon from '-!react-svg-loader!@packages/frontend-shared/src/assets/icons/status-passed_x12.svg' import PendingIcon from '-!react-svg-loader!@packages/frontend-shared/src/assets/icons/status-pending_x12.svg' diff --git a/packages/reporter/src/main.tsx b/packages/reporter/src/main.tsx index 22756a2d2d07..05e36658fc15 100644 --- a/packages/reporter/src/main.tsx +++ b/packages/reporter/src/main.tsx @@ -33,7 +33,7 @@ interface BaseReporterProps { error?: RunnablesErrorModel resetStatsOnSpecChange?: boolean renderReporterHeader?: (props: ReporterHeaderProps) => JSX.Element - experimentalStudioEnabled: boolean + studioEnabled: boolean runnerStore: MobxRunnerStore } @@ -60,13 +60,12 @@ class Reporter extends Component { scroller, error, statsStore, - experimentalStudioEnabled, + studioEnabled, renderReporterHeader = (props: ReporterHeaderProps) =>
    , } = this.props return (
    {renderReporterHeader({ appState, statsStore })} @@ -80,6 +79,7 @@ class Reporter extends Component { scroller={scroller} spec={this.props.runnerStore.spec} statsStore={this.props.statsStore} + studioEnabled={studioEnabled} /> )}
    diff --git a/packages/reporter/src/runnables/runnable-and-suite.tsx b/packages/reporter/src/runnables/runnable-and-suite.tsx index 9646f6da9aaf..402aba8ed8a6 100644 --- a/packages/reporter/src/runnables/runnable-and-suite.tsx +++ b/packages/reporter/src/runnables/runnable-and-suite.tsx @@ -20,9 +20,10 @@ import WandIcon from '-!react-svg-loader!@packages/frontend-shared/src/assets/ic interface SuiteProps { eventManager?: Events model: SuiteModel + studioEnabled: boolean } -const Suite = observer(({ eventManager = events, model }: SuiteProps) => { +const Suite = observer(({ studioEnabled, eventManager = events, model }: SuiteProps) => { const _launchStudio = (e: MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -33,7 +34,7 @@ const Suite = observer(({ eventManager = events, model }: SuiteProps) => { const _header = () => ( <> {model.title} - {appState.studioActive && ( + {studioEnabled && ( @@ -54,7 +55,12 @@ const Suite = observer(({ eventManager = events, model }: SuiteProps) => { isOpen >
      - {_.map(model.children, (runnable) => )} + {_.map(model.children, (runnable) => + ())}
    ) @@ -63,6 +69,7 @@ const Suite = observer(({ eventManager = events, model }: SuiteProps) => { export interface RunnableProps { model: TestModel | SuiteModel appState: AppState + studioEnabled: boolean } // NOTE: some of the driver tests dig into the React instance for this component @@ -76,7 +83,7 @@ class Runnable extends Component { } render () { - const { appState, model } = this.props + const { appState, model, studioEnabled } = this.props return (
  • { })} data-model-state={model.state} > - {model.type === 'test' ? : } + {model.type === 'test' + ? + : }
  • ) } diff --git a/packages/reporter/src/runnables/runnables.scss b/packages/reporter/src/runnables/runnables.scss index a9173aa83bf6..f6dd542c5eee 100644 --- a/packages/reporter/src/runnables/runnables.scss +++ b/packages/reporter/src/runnables/runnables.scss @@ -64,11 +64,6 @@ background-color: $gray-900; } - .open-studio, - .open-studio-desc { - display: none; - } - svg { margin-right: 5px; vertical-align: text-top; @@ -83,8 +78,8 @@ } } - &.experimental-studio-enabled .no-tests .open-studio, - &.experimental-studio-enabled .no-tests .open-studio-desc { + .open-studio, + .open-studio-desc { display: inline; } @@ -307,7 +302,6 @@ .runnable-controls-studio { color: $indigo-300; opacity: 0; - display: none; } } @@ -315,14 +309,6 @@ visibility: visible; } - &.experimental-studio-enabled .runnable-controls .runnable-controls-studio { - display: inline; - } - - &.studio-active .runnable-controls .runnable-controls-studio { - display: none; - } - .test .collapsible { display: flex; flex-direction: column; diff --git a/packages/reporter/src/runnables/runnables.tsx b/packages/reporter/src/runnables/runnables.tsx index 8a3e0825edc5..e6625ae2d4fb 100644 --- a/packages/reporter/src/runnables/runnables.tsx +++ b/packages/reporter/src/runnables/runnables.tsx @@ -10,7 +10,7 @@ import RunnableHeader from './runnable-header' import { RunnablesStore, RunnableArray } from './runnables-store' import statsStore, { StatsStore } from '../header/stats-store' import { Scroller } from '../lib/scroller' -import appState, { AppState } from '../lib/app-state' +import type { AppState } from '../lib/app-state' import OpenFileInIDE from '../lib/open-file-in-ide' import OpenIcon from '-!react-svg-loader!@packages/frontend-shared/src/assets/icons/technology-code-editor_x16.svg' @@ -29,9 +29,10 @@ const Loading = () => ( interface RunnablesEmptyStateProps { spec: Cypress.Cypress['spec'] eventManager?: Events + studioEnabled: boolean } -const RunnablesEmptyState = ({ spec, eventManager = events }: RunnablesEmptyStateProps) => { +const RunnablesEmptyState = ({ spec, studioEnabled, eventManager = events }: RunnablesEmptyStateProps) => { const _launchStudio = (e: MouseEvent) => { e.preventDefault() @@ -63,7 +64,7 @@ const RunnablesEmptyState = ({ spec, eventManager = events }: RunnablesEmptyStat

    Write a test using your preferred text editor.

    - {appState.studioActive && ( + {studioEnabled && ( <>

    Create test with Cypress Studio

    Use an interactive tool to author a test right here.

    @@ -79,12 +80,18 @@ const RunnablesEmptyState = ({ spec, eventManager = events }: RunnablesEmptyStat interface RunnablesListProps { runnables: RunnableArray + studioEnabled: boolean } -const RunnablesList = observer(({ runnables }: RunnablesListProps) => ( +const RunnablesList = observer(({ runnables, studioEnabled }: RunnablesListProps) => (
      - {_.map(runnables, (runnable) => )} + {_.map(runnables, (runnable) => + ())}
    )) @@ -93,9 +100,10 @@ export interface RunnablesContentProps { runnablesStore: RunnablesStore spec: Cypress.Cypress['spec'] error?: RunnablesErrorModel + studioEnabled: boolean } -const RunnablesContent = observer(({ runnablesStore, spec, error }: RunnablesContentProps) => { +const RunnablesContent = observer(({ runnablesStore, spec, error, studioEnabled }: RunnablesContentProps) => { const { isReady, runnables, runnablesHistory } = runnablesStore if (!isReady) { @@ -105,7 +113,7 @@ const RunnablesContent = observer(({ runnablesStore, spec, error }: RunnablesCon // show error if there are no tests, but only if there // there isn't an error passed down that supercedes it if (!error && !runnablesStore.runnables.length) { - return + return } if (error) { @@ -116,7 +124,12 @@ const RunnablesContent = observer(({ runnablesStore, spec, error }: RunnablesCon const isRunning = specPath === runnablesStore.runningSpec - return + return ( + + ) }) export interface RunnablesProps { @@ -126,18 +139,20 @@ export interface RunnablesProps { spec: Cypress.Cypress['spec'] scroller: Scroller appState?: AppState + studioEnabled: boolean } @observer class Runnables extends Component { render () { - const { error, runnablesStore, spec } = this.props + const { error, runnablesStore, spec, studioEnabled } = this.props return (
    diff --git a/packages/reporter/src/runnables/suite-model.ts b/packages/reporter/src/runnables/suite-model.ts index f227115071b7..19d81c4a91b7 100644 --- a/packages/reporter/src/runnables/suite-model.ts +++ b/packages/reporter/src/runnables/suite-model.ts @@ -1,7 +1,8 @@ import _ from 'lodash' import { computed, observable } from 'mobx' import Runnable, { RunnableProps } from './runnable-model' -import TestModel, { TestProps, TestState } from '../test/test-model' +import TestModel, { TestProps } from '../test/test-model' +import { TestState } from '@packages/types' export interface SuiteProps extends RunnableProps { suites: Array diff --git a/packages/reporter/src/test/test-model.ts b/packages/reporter/src/test/test-model.ts index fecc4302c4b6..e7955c735125 100644 --- a/packages/reporter/src/test/test-model.ts +++ b/packages/reporter/src/test/test-model.ts @@ -1,7 +1,7 @@ import _ from 'lodash' import { action, computed, observable } from 'mobx' -import { FileDetails } from '@packages/types' +import { FileDetails, TestState } from '@packages/types' import Attempt from '../attempts/attempt-model' import Err, { ErrProps } from '../errors/err-model' import { HookProps } from '../hooks/hook-model' @@ -12,8 +12,6 @@ import { RouteProps } from '../routes/route-model' import { RunnablesStore, LogProps } from '../runnables/runnables-store' import { SessionProps } from '../sessions/sessions-model' -export type TestState = 'active' | 'failed' | 'pending' | 'passed' | 'processing' - export type UpdateTestCallback = () => void export interface TestProps extends RunnableProps { diff --git a/packages/reporter/src/test/test.cy.tsx b/packages/reporter/src/test/test.cy.tsx index 9d33be83dc63..060b679e0825 100644 --- a/packages/reporter/src/test/test.cy.tsx +++ b/packages/reporter/src/test/test.cy.tsx @@ -16,7 +16,11 @@ describe('test/test.tsx', () => { } cy.mount(
    - +
    ) cy.percySnapshot() diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 5db23ad94cc8..650a1608f3a6 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -106,6 +106,7 @@ interface TestProps { runnablesStore: RunnablesStore scroller: Scroller model: TestModel + studioEnabled: boolean } @observer @@ -192,7 +193,7 @@ class Test extends Component { ) } - if (appState.studioActive) { + if (this.props.studioEnabled) { controls.push( diff --git a/packages/runner/src/studio/studio-recorder.js b/packages/runner/src/studio/studio-recorder.js deleted file mode 100644 index 7da29359f017..000000000000 --- a/packages/runner/src/studio/studio-recorder.js +++ /dev/null @@ -1,840 +0,0 @@ -import { action, computed, observable } from 'mobx' -import $ from 'jquery' -import $driverUtils from '@packages/driver/src/cypress/utils' -import { dom } from '../dom' - -const saveErrorMessage = (message) => { - return `\ -${message}\n\n\ -Cypress was unable to save these commands to your spec file. \ -You can use the copy button below to copy the commands to your clipboard. \ -\n -Cypress Studio is still in beta and the team is working hard to \ -resolve issues like this. To help us fix this issue more quickly, \ -you can provide us with more information by clicking 'Learn more' below.` -} - -const eventTypes = [ - 'click', - // 'dblclick', - 'change', - 'keydown', - 'keyup', -] - -const eventsWithValue = [ - 'change', - 'keydown', - 'keyup', -] - -const internalMouseEvents = [ - 'mousedown', - 'mouseover', - 'mouseout', -] - -const tagNamesWithoutText = [ - 'SELECT', - 'INPUT', - 'TEXTAREA', -] - -const tagNamesWithValue = [ - 'BUTTON', - 'INPUT', - 'METER', - 'LI', - 'OPTION', - 'PROGESS', - 'PARAM', - 'TEXTAREA', -] - -export class StudioRecorder { - @observable testId = null - @observable suiteId = null - @observable initModalIsOpen = false - @observable saveModalIsOpen = false - @observable logs = [] - @observable isLoading = false - @observable isActive = false - @observable url = null - @observable isFailed = false - @observable _hasStarted = false - - fileDetails = null - absoluteFile = null - runnableTitle = null - _currentId = 1 - _previousMouseEvent = null - - constructor (eventManager) { - this.eventManager = eventManager - } - - @computed get hasRunnableId () { - return !!this.testId || !!this.suiteId - } - - @computed get isOpen () { - return this.isActive || this.isLoading || this._hasStarted - } - - @computed get isEmpty () { - return this.logs.length === 0 - } - - @computed get isReady () { - return this.isOpen && this.isEmpty && !this.isLoading && !this.isFailed - } - - @computed get hookId () { - return `${this.testId}-studio` - } - - @computed get needsUrl () { - return this.isActive && !this.url && !this.isFailed - } - - @computed get testError () { - return { - id: this.testId, - state: 'failed', - } - } - - @computed get state () { - return { - testId: this.testId, - suiteId: this.suiteId, - url: this.url, - } - } - - get Cypress () { - return this.eventManager.getCypress() - } - - saveError (err) { - return { - id: this.testId, - err: { - ...err, - message: saveErrorMessage(err.message), - docsUrl: 'https://on.cypress.io/studio-beta', - }, - } - } - - @action setTestId = (testId) => { - this.testId = testId - } - - @action setSuiteId = (suiteId) => { - this.suiteId = suiteId - this.testId = null - } - - @action clearRunnableIds = () => { - this.testId = null - this.suiteId = null - } - - @action showInitModal = () => { - this.initModalIsOpen = true - } - - @action closeInitModal = () => { - this.initModalIsOpen = false - } - - @action showSaveModal = () => { - this.saveModalIsOpen = true - } - - @action closeSaveModal = () => { - this.saveModalIsOpen = false - } - - @action startLoading = () => { - this.isLoading = true - } - - @action setInactive = () => { - this.isActive = false - } - - @action setUrl = (url) => { - this.url = url - } - - @action testFailed = () => { - this.isFailed = true - } - - setFileDetails = (fileDetails) => { - this.fileDetails = fileDetails - } - - setAbsoluteFile = (absoluteFile) => { - this.absoluteFile = absoluteFile - } - - setRunnableTitle = (runnableTitle) => { - this.runnableTitle = runnableTitle - } - - _clearPreviousMouseEvent = () => { - this._previousMouseEvent = null - } - - _matchPreviousMouseEvent = (el) => { - return this._previousMouseEvent && $(el).is(this._previousMouseEvent.element) - } - - @action initialize = (config, state) => { - const { studio } = state - - if (studio) { - if (studio.testId) { - this.setTestId(studio.testId) - } - - if (studio.suiteId) { - this.setSuiteId(studio.suiteId) - } - - if (studio.url) { - this.setUrl(studio.url) - } - } - - if (this.hasRunnableId) { - this.setAbsoluteFile(config.spec.absolute) - this.startLoading() - - if (this.suiteId) { - this.Cypress.runner.setOnlySuiteId(this.suiteId) - } else if (this.testId) { - this.Cypress.runner.setOnlyTestId(this.testId) - } - } - } - - @action interceptTest = (test) => { - if (this.suiteId) { - this.setTestId(test.id) - } - - if (this.hasRunnableId) { - if (test.invocationDetails) { - this.setFileDetails(test.invocationDetails) - } - - if (this.suiteId) { - if (test.parent && test.parent.id !== 'r1') { - this.setRunnableTitle(test.parent.title) - } - } else { - this.setRunnableTitle(test.title) - } - } - } - - @action start = (body) => { - this.isActive = true - this.isLoading = false - this.logs = [] - this._currentId = 1 - this._hasStarted = true - - if (this.url) { - this.visitUrl() - } - - this.attachListeners(body) - } - - @action stop = () => { - this.removeListeners() - - this.isActive = false - this.isLoading = false - } - - @action reset = () => { - this.stop() - - this.logs = [] - this.url = null - this._hasStarted = false - this._currentId = 1 - this.isFailed = false - } - - @action cancel = () => { - this.reset() - this.clearRunnableIds() - } - - @action startSave = () => { - if (this.suiteId) { - this.showSaveModal() - } else { - this.save() - } - } - - @action save = (testName = null) => { - this.closeSaveModal() - this.stop() - - this.eventManager.emit('studio:save', { - fileDetails: this.fileDetails, - absoluteFile: this.absoluteFile, - runnableTitle: this.runnableTitle, - commands: this.logs, - isSuite: !!this.suiteId, - isRoot: this.suiteId === 'r1', - testName, - }) - } - - @action visitUrl = (url = this.url) => { - this.setUrl(url) - - this.Cypress.cy.visit(this.url) - - this.logs.push({ - id: this._getId(), - selector: null, - name: 'visit', - message: this.url, - }) - } - - attachListeners = (body) => { - if (this.isFailed) return - - this._body = body - - eventTypes.forEach((event) => { - this._body.addEventListener(event, this._recordEvent, { - capture: true, - passive: true, - }) - }) - - internalMouseEvents.forEach((event) => { - this._body.addEventListener(event, this._recordMouseEvent, { - capture: true, - passive: true, - }) - }) - - this._body.addEventListener('contextmenu', this._openAssertionsMenu, { - capture: true, - }) - - this._clearPreviousMouseEvent() - } - - removeListeners = () => { - if (!this._body) return - - eventTypes.forEach((event) => { - this._body.removeEventListener(event, this._recordEvent, { - capture: true, - }) - }) - - internalMouseEvents.forEach((event) => { - this._body.removeEventListener(event, this._recordMouseEvent, { - capture: true, - }) - }) - - this._body.removeEventListener('contextmenu', this._openAssertionsMenu, { - capture: true, - }) - - this._clearPreviousMouseEvent() - } - - copyToClipboard = (commandsText) => { - // clipboard API is not supported without secure context - if (window.isSecureContext && navigator.clipboard) { - return navigator.clipboard.writeText(commandsText) - } - - // fallback to creating invisible textarea - // create the textarea in our document rather than this._body - // as to not interfere with the app in the aut - const textArea = document.createElement('textarea') - - textArea.value = commandsText - textArea.style.position = 'fixed' - textArea.style.opacity = 0 - - document.body.appendChild(textArea) - textArea.select() - document.execCommand('copy') - textArea.remove() - - return Promise.resolve() - } - - _trustEvent = (event) => { - // only capture events sent by the actual user - // but disable the check if we're in a test - return event.isTrusted || this.Cypress.env('INTERNAL_E2E_TESTS') === 1 - } - - _recordMouseEvent = (event) => { - if (!this._trustEvent(event)) return - - const { type, target } = event - - if (type === 'mouseout') { - return this._clearPreviousMouseEvent() - } - - // we only replace the previous mouse event if the element is different - // since we want to use the oldest possible selector - if (!this._matchPreviousMouseEvent(target)) { - this._previousMouseEvent = { - element: target, - selector: this.Cypress.SelectorPlayground.getSelector($(target)), - } - } - } - - _getId = () => { - return this._currentId++ - } - - _getName = (event, $el) => { - const tagName = $el.prop('tagName') - const { type } = event - - if (tagName === 'SELECT' && type === 'change') { - return 'select' - } - - if (type === 'keydown' || type === 'keyup') { - return 'type' - } - - if (type === 'click' && tagName === 'INPUT') { - const inputType = $el.prop('type') - const checked = $el.prop('checked') - - if (inputType === 'radio' || (inputType === 'checkbox' && checked)) { - return 'check' - } - - if (inputType === 'checkbox') { - return 'uncheck' - } - } - - return type - } - - _getMessage = (event, $el) => { - if (!eventsWithValue.includes(event.type)) { - return null - } - - let val = $el.val() - - if (event.type === 'keydown' || event.type === 'keyup') { - val = val.replace(/{/g, '{{}') - - if (event.key === 'Enter') { - val = `${val}{enter}` - } - } - - return val - } - - _shouldRecordEvent = (event, $el) => { - const tagName = $el.prop('tagName') - - // only want to record keystrokes within input elements - if ((event.type === 'keydown' || event.type === 'keyup') && tagName !== 'INPUT') { - return false - } - - // we record all normal keys on keyup (rather than keydown) since the input value will be updated - // we do not record enter on keyup since a form submission will have already been triggered - if (event.type === 'keyup' && event.key === 'Enter') { - return false - } - - // we record enter on keydown since this happens before a form submission is triggered - // all other keys are recorded on keyup - if (event.type === 'keydown' && event.key !== 'Enter') { - return false - } - - // cy cannot click on a select - if (tagName === 'SELECT' && event.type === 'click') { - return false - } - - // do not record clicks on option elements since this is handled with cy.select() - if (tagName === 'OPTION') { - return false - } - - return true - } - - @action _recordEvent = (event) => { - if (this.isFailed || !this._trustEvent(event)) return - - const $el = $(event.target) - - if (this._isAssertionsMenu($el)) { - return - } - - this._closeAssertionsMenu() - - if (!this._shouldRecordEvent(event, $el)) { - return - } - - const name = this._getName(event, $el) - const message = this._getMessage(event, $el) - - if (name === 'change') { - return - } - - let selector = '' - - if (name === 'click' && this._matchPreviousMouseEvent($el)) { - selector = this._previousMouseEvent.selector - } else { - selector = this.Cypress.SelectorPlayground.getSelector($el) - } - - this._clearPreviousMouseEvent() - - if (name === 'type' && !message) { - return this._removeLastLogIfType(selector) - } - - const updateOnly = this._updateLastLog(selector, name, message) - - if (updateOnly) { - return - } - - if (name === 'type') { - this._addClearLog(selector) - } - - this._addLog({ - selector, - name, - message, - }) - } - - @action _removeLastLogIfType = (selector) => { - const lastLog = this.logs[this.logs.length - 1] - - if (lastLog.selector === selector && lastLog.name === 'type') { - return this.removeLog(lastLog.id) - } - } - - @action removeLog = (commandId) => { - const index = this.logs.findIndex((command) => command.id === commandId) - const log = this.logs[index] - - this.logs.splice(index, 1) - - this._generateBothLogs(log).forEach((commandLog) => { - this.eventManager.emit('reporter:log:remove', commandLog) - }) - } - - _generateLog = ({ id, name, message, type, number }) => { - return { - id, - testId: this.testId, - hookId: this.hookId, - name, - message: message ? $driverUtils.stringifyActual(message) : null, - type, - state: 'passed', - instrument: 'command', - number, - numElements: 1, - isStudio: true, - } - } - - _generateBothLogs = (log) => { - return [ - this._generateLog({ - id: `s${log.id}-get`, - name: 'get', - message: log.selector, - type: 'parent', - number: log.id, - }), - this._generateLog({ - id: `s${log.id}`, - name: log.name, - message: log.message, - type: 'child', - }), - ] - } - - @action _addLog = (log) => { - log.id = this._getId() - - this.logs.push(log) - - this._generateBothLogs(log).forEach((commandLog) => { - this.eventManager.emit('reporter:log:add', commandLog) - }) - } - - _addClearLog = (selector) => { - const lastLog = this.logs[this.logs.length - 1] - - if (lastLog && lastLog.name === 'clear' && lastLog.selector === selector) { - return - } - - this._addLog({ - selector, - name: 'clear', - message: null, - }) - } - - _updateLog = (log) => { - const { id, name, message } = log - - this.eventManager.emit('reporter:log:state:changed', this._generateLog({ - id: `s${id}`, - name, - message, - type: 'child', - })) - } - - _updateLastLog = (selector, name, message) => { - const { length } = this.logs - - if (!length) { - return false - } - - const lastLog = this.logs[length - 1] - - const updateLog = (newName = name, newMessage = message) => { - lastLog.message = newMessage - lastLog.name = newName - - this._updateLog(lastLog) - } - - if (selector === lastLog.selector) { - if (name === 'type' && lastLog.name === 'type') { - updateLog() - - return true - } - - // Cypress automatically issues a .click before every type - // so we can turn the extra click event into the .clear that comes before every type - if (name === 'type' && lastLog.name === 'click') { - updateLog('clear', null) - - // we return false since we still need to add the type log - return false - } - } - - return false - } - - @action _addAssertion = ($el, ...args) => { - const id = this._getId() - const selector = this.Cypress.SelectorPlayground.getSelector($el) - - const log = { - id, - selector, - name: 'should', - message: args, - isAssertion: true, - } - - this.logs.push(log) - - const reporterLog = { - id, - selector, - name: 'assert', - message: this._generateAssertionMessage($el, args), - } - - this._generateBothLogs(reporterLog).forEach((commandLog) => { - this.eventManager.emit('reporter:log:add', commandLog) - }) - - this._closeAssertionsMenu() - } - - _generateAssertionMessage = ($el, args) => { - const elementString = $driverUtils.stringifyActual($el) - const assertionString = args[0].replace(/\./g, ' ') - - let message = `expect **${elementString}** to ${assertionString}` - - if (args[1]) { - message = `${message} **${args[1]}**` - } - - if (args[2]) { - message = `${message} with the value **${args[2]}**` - } - - return message - } - - _isAssertionsMenu = ($el) => { - return $el.hasClass('__cypress-studio-assertions-menu') - } - - _openAssertionsMenu = (event) => { - event.preventDefault() - event.stopPropagation() - - const $el = $(event.target) - - if (this._isAssertionsMenu($el)) { - return - } - - this._closeAssertionsMenu() - - dom.openStudioAssertionsMenu({ - $el, - $body: $(this._body), - props: { - possibleAssertions: this._generatePossibleAssertions($el), - addAssertion: this._addAssertion, - closeMenu: this._closeAssertionsMenu, - }, - }) - } - - _closeAssertionsMenu = () => { - dom.closeStudioAssertionsMenu($(this._body)) - } - - _generatePossibleAssertions = ($el) => { - const tagName = $el.prop('tagName') - - const possibleAssertions = [] - - if (!tagNamesWithoutText.includes(tagName)) { - const text = $el.text() - - if (text) { - possibleAssertions.push({ - type: 'have.text', - options: [{ - value: text, - }], - }) - } - } - - if (tagNamesWithValue.includes(tagName)) { - const val = $el.val() - - if (val !== undefined && val !== '') { - possibleAssertions.push({ - type: 'have.value', - options: [{ - value: val, - }], - }) - } - } - - const attributes = $.map($el[0].attributes, ({ name, value }) => { - if (name === 'value' || name === 'disabled') return - - if (name === 'class') { - possibleAssertions.push({ - type: 'have.class', - options: value.split(' ').map((value) => ({ value })), - }) - - return - } - - if (name === 'id') { - possibleAssertions.push({ - type: 'have.id', - options: [{ - value, - }], - }) - - return - } - - if (value !== undefined && value !== '') { - return { - name, - value, - } - } - }) - - if (attributes.length > 0) { - possibleAssertions.push({ - type: 'have.attr', - options: attributes, - }) - } - - possibleAssertions.push({ - type: 'be.visible', - }) - - const isDisabled = $el.prop('disabled') - - if (isDisabled !== undefined) { - possibleAssertions.push({ - type: isDisabled ? 'be.disabled' : 'be.enabled', - }) - } - - const isChecked = $el.prop('checked') - - if (isChecked !== undefined) { - possibleAssertions.push({ - type: isChecked ? 'be.checked' : 'not.be.checked', - }) - } - - return possibleAssertions - } -} diff --git a/packages/runner/src/studio/studio-recorder.spec.js b/packages/runner/src/studio/studio-recorder.spec.js deleted file mode 100644 index f451e105b3ee..000000000000 --- a/packages/runner/src/studio/studio-recorder.spec.js +++ /dev/null @@ -1,1421 +0,0 @@ -import sinon from 'sinon' -import $ from 'jquery' -import driver from '@packages/driver' -import { dom } from '../dom' -import { createEventManager } from '../../test/utils' - -import { StudioRecorder } from './studio-recorder' - -const createEvent = (props) => { - return { - isTrusted: true, - type: 'click', - preventDefault: sinon.stub(), - stopPropagation: sinon.stub(), - ...props, - } -} - -describe('StudioRecorder', () => { - let eventManager - const cyVisitStub = sinon.stub() - const getSelectorStub = sinon.stub().returns('.selector') - const setOnlyTestIdStub = sinon.stub() - const setOnlySuiteIdStub = sinon.stub() - - let instance - - beforeEach(() => { - eventManager = createEventManager() - instance = new StudioRecorder(eventManager) - - sinon.stub(instance, 'attachListeners') - sinon.stub(instance, 'removeListeners') - - sinon.stub(dom, 'closeStudioAssertionsMenu') - sinon.stub(dom, 'openStudioAssertionsMenu') - - driver.$ = $ - - sinon.stub(eventManager, 'emit') - sinon.stub(eventManager, 'getCypress').returns({ - cy: { - visit: cyVisitStub, - }, - SelectorPlayground: { - getSelector: getSelectorStub, - }, - runner: { - setOnlyTestId: setOnlyTestIdStub, - setOnlySuiteId: setOnlySuiteIdStub, - }, - env: () => null, - }) - }) - - afterEach(() => { - sinon.restore() - }) - - context('#startLoading', () => { - it('sets isLoading, isOpen to true', () => { - instance.startLoading() - - expect(instance.isLoading).to.be.true - expect(instance.isOpen).to.be.true - }) - }) - - context('#setTestId', () => { - it('sets testId to id and hasRunnableId to true', () => { - instance.setTestId('r2') - - expect(instance.testId).to.equal('r2') - expect(instance.hasRunnableId).to.be.true - expect(instance.state.testId).to.equal('r2') - }) - - it('does not clear suite id', () => { - instance.suiteId = 'r1' - instance.setTestId('r2') - - expect(instance.suiteId).to.equal('r1') - }) - }) - - context('#setSuiteId', () => { - it('sets suiteId to id and hasRunnableId to true', () => { - instance.setSuiteId('r1') - - expect(instance.suiteId).to.equal('r1') - expect(instance.hasRunnableId).to.be.true - expect(instance.state.suiteId).to.equal('r1') - }) - - it('clears test id', () => { - instance.testId = 'r2' - instance.setSuiteId('r1') - - expect(instance.testId).to.be.null - }) - }) - - context('#initialize', () => { - const config = { - spec: { - absolute: '/path/to/spec.js', - }, - } - - it('restores state, grabs config info, and initializes driver when extending test', () => { - const state = { - studio: { - testId: 'r2', - suiteId: null, - url: null, - }, - } - - instance.initialize(config, state) - - expect(instance.testId).to.equal('r2') - expect(instance.absoluteFile).to.equal('/path/to/spec.js') - expect(instance.isLoading).to.be.true - expect(setOnlyTestIdStub).to.be.calledWith('r2') - }) - - it('restores state, grabs config info, and initializes driver when adding to suite', () => { - const state = { - studio: { - testId: null, - suiteId: 'r1', - url: 'https://example.com', - }, - } - - instance.initialize(config, state) - - expect(instance.suiteId).to.equal('r1') - expect(instance.url).to.equal('https://example.com') - expect(instance.absoluteFile).to.equal('/path/to/spec.js') - expect(instance.isLoading).to.be.true - expect(setOnlySuiteIdStub).to.be.calledWith('r1') - }) - - it('grabs config info and initializes driver when state already exists while extending test', () => { - instance.setTestId('r2') - - instance.initialize(config, {}) - - expect(instance.absoluteFile).to.equal('/path/to/spec.js') - expect(instance.isLoading).to.be.true - expect(setOnlyTestIdStub).to.be.calledWith('r2') - }) - - it('grabs config info and initializes driver when state already exists while adding to suite', () => { - instance.setSuiteId('r1') - - instance.initialize(config, {}) - - expect(instance.absoluteFile).to.equal('/path/to/spec.js') - expect(instance.isLoading).to.be.true - expect(setOnlySuiteIdStub).to.be.calledWith('r1') - }) - }) - - context('#interceptTest', () => { - it('grabs test data when extending test', () => { - const invocationDetails = { - absoluteFile: '/path/to/spec', - line: 20, - column: 5, - } - - const test = { - id: 'r2', - title: 'my test', - invocationDetails, - } - - instance.setTestId('r2') - - instance.interceptTest(test) - - expect(instance.fileDetails).to.equal(invocationDetails) - expect(instance.runnableTitle).to.equal('my test') - }) - - it('grabs test and suite data when adding to suite', () => { - const invocationDetails = { - absoluteFile: '/path/to/spec', - line: 20, - column: 5, - } - - const suite = { - id: 'r2', - title: 'my suite', - } - - const test = { - id: 'r3', - title: 'my test', - invocationDetails, - parent: suite, - } - - instance.setSuiteId('r2') - - instance.interceptTest(test) - - expect(instance.testId).to.equal('r3') - expect(instance.fileDetails).to.equal(invocationDetails) - expect(instance.runnableTitle).to.equal('my suite') - }) - - it('does not grab parent title when adding to root', () => { - const root = { - id: 'r1', - title: '', - } - - const test = { - id: 'r2', - title: 'my test', - parent: root, - } - - instance.setSuiteId('r1') - - instance.interceptTest(test) - - expect(instance.testId).to.equal('r2') - expect(instance.fileDetails).to.be.null - expect(instance.runnableTitle).to.be.null - }) - }) - - context('#start', () => { - beforeEach(() => { - sinon.stub(instance, 'visitUrl') - }) - - it('sets isActive, isOpen to true and isLoading to false', () => { - instance.start(null) - - expect(instance.isActive).to.be.true - expect(instance.isLoading).to.be.false - expect(instance.isOpen).to.be.true - }) - - it('clears any existing logs', () => { - instance.logs = ['log 1', 'log 2'] - instance.start(null) - - expect(instance.logs).to.be.empty - }) - - it('visits url if url has been set', () => { - instance.url = 'cypress.io' - instance.start(null) - - expect(instance.visitUrl).to.be.called - }) - - it('attaches listeners to the body', () => { - instance.start('body') - - expect(instance.attachListeners).to.be.calledWith('body') - }) - }) - - context('#stop', () => { - beforeEach(() => { - instance.start() - }) - - it('removes listeners', () => { - instance.stop() - - expect(instance.removeListeners).to.be.called - }) - - it('sets isActive, isLoading to false and isOpen is true', () => { - instance.stop() - - expect(instance.isActive).to.be.false - expect(instance.isOpen).to.be.true - }) - }) - - context('#reset', () => { - beforeEach(() => { - instance.start() - }) - - it('removes listeners', () => { - instance.reset() - - expect(instance.removeListeners).to.be.called - }) - - it('sets isActive, isOpen to false', () => { - instance.reset() - - expect(instance.isActive).to.be.false - expect(instance.isOpen).to.be.false - }) - - it('clears logs and url', () => { - instance.reset() - - expect(instance.logs).to.be.empty - expect(instance.url).to.be.null - }) - - it('does not remove runnable ids', () => { - instance.testId = 'r2' - instance.suiteId = 'r1' - instance.reset() - - expect(instance.hasRunnableId).to.be.true - }) - }) - - context('#cancel', () => { - beforeEach(() => { - instance.start() - }) - - it('removes listeners', () => { - instance.cancel() - - expect(instance.removeListeners).to.be.called - }) - - it('sets isActive, isOpen to false', () => { - instance.cancel() - - expect(instance.isActive).to.be.false - expect(instance.isOpen).to.be.false - }) - - it('clears logs and url', () => { - instance.logs = ['log 1', 'log 2'] - instance.cancel() - - expect(instance.logs).to.be.empty - expect(instance.url).to.be.null - }) - - it('removes runnable ids', () => { - instance.testId = 'r2' - instance.suiteId = 'r1' - instance.cancel() - - expect(instance.hasRunnableId).to.be.false - }) - }) - - context('#startSave', () => { - beforeEach(() => { - instance.start() - }) - - it('shows save modal if suite', () => { - instance.suiteId = 'r1' - instance.startSave() - - expect(instance.saveModalIsOpen).to.be.true - }) - - it('skips modal and goes directly to save if test', () => { - sinon.stub(instance, 'save') - - instance.testId = 'r2' - instance.startSave() - - expect(instance.save).to.be.called - }) - }) - - context('#save', () => { - beforeEach(() => { - instance.start() - }) - - it('closes save modal', () => { - instance.showSaveModal() - instance.save() - - expect(instance.saveModalIsOpen).to.be.false - }) - - it('removes listeners', () => { - instance.save() - - expect(instance.removeListeners).to.be.called - }) - - it('sets isActive to false and isOpen is true', () => { - instance.save() - - expect(instance.isActive).to.be.false - expect(instance.isOpen).to.be.true - }) - - it('emits studio:save with relevant test information', () => { - const fileDetails = { - absoluteFile: '/path/to/spec.js', - line: 10, - column: 4, - } - const absoluteFile = '/path/to/spec.js' - const runnableTitle = 'my test' - const logs = ['log 1', 'log 2'] - - instance.setFileDetails(fileDetails) - instance.setAbsoluteFile(absoluteFile) - instance.setRunnableTitle(runnableTitle) - instance.logs = logs - instance.testId = 'r2' - - instance.save() - - expect(eventManager.emit).to.be.calledWith('studio:save', { - fileDetails, - absoluteFile, - runnableTitle, - commands: logs, - isSuite: false, - isRoot: false, - testName: null, - }) - }) - - it('emits studio:save with relevant suite information', () => { - const fileDetails = { - absoluteFile: '/path/to/spec.js', - line: 10, - column: 4, - } - const absoluteFile = '/path/to/spec.js' - const runnableTitle = 'my suite' - const logs = ['log 1', 'log 2'] - - instance.setFileDetails(fileDetails) - instance.setAbsoluteFile(absoluteFile) - instance.setRunnableTitle(runnableTitle) - instance.logs = logs - instance.suiteId = 'r2' - - instance.save('new test name') - - expect(eventManager.emit).to.be.calledWith('studio:save', { - fileDetails, - absoluteFile, - runnableTitle, - commands: logs, - isSuite: true, - isRoot: false, - testName: 'new test name', - }) - }) - - it('emits studio:save with relevant suite information for root suite', () => { - const absoluteFile = '/path/to/spec.js' - const logs = ['log 1', 'log 2'] - - instance.setAbsoluteFile(absoluteFile) - instance.logs = logs - instance.suiteId = 'r1' - - instance.save('new test name') - - expect(eventManager.emit).to.be.calledWith('studio:save', { - fileDetails: null, - absoluteFile, - runnableTitle: null, - commands: logs, - isSuite: true, - isRoot: true, - testName: 'new test name', - }) - }) - }) - - context('#visitUrl', () => { - it('visits existing url by default', () => { - instance.url = 'cypress.io' - instance.visitUrl() - - expect(cyVisitStub).to.be.calledWith('cypress.io') - }) - - it('visits and sets new url', () => { - instance.visitUrl('example.com') - - expect(instance.url).to.equal('example.com') - expect(instance.state.url).to.equal('example.com') - expect(cyVisitStub).to.be.calledWith('example.com') - }) - - it('adds a log for the visited url', () => { - instance.visitUrl('cypress.io') - - expect(instance.logs[0].selector).to.be.null - expect(instance.logs[0].name).to.equal('visit') - expect(instance.logs[0].message).to.equal('cypress.io') - }) - }) - - context('#copyToClipboard', () => { - const textToBeCopied = 'cy.get(\'.btn\').click()' - - afterEach(() => { - delete window.isSecureContext - delete navigator.clipboard - delete document.execCommand - }) - - it('uses clipboard api when available', () => { - const writeText = sinon.stub().resolves() - - window.isSecureContext = true - navigator.clipboard = { - writeText, - } - - return instance.copyToClipboard(textToBeCopied).then(() => { - expect(writeText).to.be.calledWith(textToBeCopied) - }) - }) - - it('falls back to execCommand when clipboard api not available', () => { - const execCommand = sinon.stub() - - document.execCommand = execCommand - - instance.copyToClipboard(textToBeCopied).then(() => { - expect(execCommand).to.be.calledWith('copy') - }) - }) - }) - - // https://github.com/cypress-io/cypress/issues/14658 - context('#recordMouseEvent', () => { - beforeEach(() => { - instance.testId = 'r2' - }) - - it('does not record events not sent by the user', () => { - instance._recordMouseEvent(createEvent({ isTrusted: false })) - - expect(instance._previousMouseEvent).to.be.null - }) - - it('records the selector and element for an event', () => { - const el = $('
    ')[0] - - instance._recordMouseEvent(createEvent({ target: el, type: 'mouseover' })) - - expect(instance._previousMouseEvent.selector).to.equal('.selector') - expect(instance._previousMouseEvent.element).to.equal(el) - }) - - it('clears previous event on mouseout', () => { - const el = $('
    ')[0] - - instance._previousMouseEvent = { - selector: '.selector', - element: el, - } - - instance._recordMouseEvent(createEvent({ target: el, type: 'mouseout' })) - - expect(instance._previousMouseEvent).to.be.null - }) - - it('replaces previous mouse event if element is different', () => { - const el1 = $('
    ')[0] - const el2 = $('

    ')[0] - - instance._previousMouseEvent = { - selector: '.previous-selector', - element: el1, - } - - instance._recordMouseEvent(createEvent({ target: el2, type: 'mouseover' })) - - expect(instance._previousMouseEvent.selector).to.equal('.selector') - expect(instance._previousMouseEvent.element).to.equal(el2) - }) - - it('does not replace previous mouse event if element is the same', () => { - const el = $('

    ')[0] - - instance._previousMouseEvent = { - selector: '.previous-selector', - element: el, - } - - instance._recordMouseEvent(createEvent({ target: el, type: 'mousedown' })) - - expect(instance._previousMouseEvent.selector).to.equal('.previous-selector') - expect(instance._previousMouseEvent.element).to.equal(el) - }) - }) - - context('#getName', () => { - it('returns the event type by default', () => { - const $el = $('
    ') - const name = instance._getName(createEvent({ type: 'click' }), $el) - - expect(name).to.equal('click') - }) - - it('returns select when a select changes', () => { - const $el = $('') - const name = instance._getName(createEvent({ type: 'keydown' }), $el) - - expect(name).to.equal('type') - }) - - it('returns check on radio button click', () => { - const $el = $('') - const name = instance._getName(createEvent({ type: 'click' }), $el) - - expect(name).to.equal('check') - }) - - it('returns check when checkbox is checked', () => { - const $el = $('') - const name = instance._getName(createEvent({ type: 'click' }), $el) - - expect(name).to.equal('check') - }) - - it('returns uncheck when checkbox is unchecked', () => { - const $el = $('') - const name = instance._getName(createEvent({ type: 'click' }), $el) - - expect(name).to.equal('uncheck') - }) - }) - - context('#getMessage', () => { - it('returns null if the event has no value', () => { - const $el = $('
    ') - const message = instance._getMessage(createEvent({ type: 'click' }), $el) - - expect(message).to.be.null - }) - - it('returns target value if the event has a value', () => { - const $el = $('') - const message = instance._getMessage(createEvent({ type: 'change' }), $el) - - expect(message).to.equal('blue') - }) - - it('returns input value on keyup', () => { - const $el = $('') - const message = instance._getMessage(createEvent({ type: 'keyup', key: 'e' }), $el) - - expect(message).to.equal('value') - }) - - it('returns input value on keyup for special keys', () => { - const $el = $('') - const message = instance._getMessage(createEvent({ type: 'keydown', key: 'Backspace' }), $el) - - expect(message).to.equal('value') - }) - - it('returns input value with { escaped', () => { - const $el = $('') - const message = instance._getMessage(createEvent({ type: 'keydown', key: '}' }), $el) - - expect(message).to.equal('my{{}value}') - }) - - it('returns input value with {enter} on enter keydown', () => { - const $el = $('') - const message = instance._getMessage(createEvent({ type: 'keydown', key: 'Enter' }), $el) - - expect(message).to.equal('value{enter}') - }) - - it('returns array if value is an array', () => { - const $el = $('') - - $el.val(['0', '1']) - - const message = instance._getMessage(createEvent({ type: 'change' }), $el) - - expect(message).to.eql(['0', '1']) - }) - }) - - context('#recordEvent', () => { - beforeEach(() => { - instance.testId = 'r2' - }) - - it('does not record events not sent by the user', () => { - instance._recordEvent(createEvent({ isTrusted: false })) - - expect(instance.logs).to.be.empty - }) - - it('does not prevent the action from reaching other event listeners', () => { - const $el = $('
    ') - - const preventDefault = sinon.stub() - const stopPropagation = sinon.stub() - - instance._recordEvent(createEvent({ target: $el, preventDefault, stopPropagation })) - - expect(preventDefault).not.to.be.called - expect(stopPropagation).not.to.be.called - }) - - it('does not record events if the test has failed', () => { - instance.testFailed() - - const $el = $('
    ') - - instance._recordEvent(createEvent({ target: $el })) - - expect(instance.logs).to.be.empty - }) - - it('does not record events inside the assertions menu', () => { - const $el = $('
    ') - - instance._recordEvent(createEvent({ target: $el })) - - expect(instance.logs).to.be.empty - }) - - it('closes the assertions menu when recording an event', () => { - const $el = $('
    ') - - instance._recordEvent(createEvent({ target: $el })) - - expect(dom.closeStudioAssertionsMenu).to.be.called - }) - - it('does not close the assertions menu on events inside the assertions menu', () => { - const $el = $('
    ') - - instance._recordEvent(createEvent({ target: $el })) - - expect(dom.closeStudioAssertionsMenu).not.to.be.called - }) - - it('uses the selector playground to get a selector for the element', () => { - const $el = $('
    ') - - instance._recordEvent(createEvent({ target: $el })) - - expect(getSelectorStub).to.be.calledWith($el) - }) - - it('uses the selector from a previously recorded mouse event on click', () => { - const el = $('
    ')[0] - - instance._previousMouseEvent = { - selector: '.previous-selector', - element: el, - } - - instance._recordEvent(createEvent({ type: 'click', target: el })) - - expect(instance.logs[0].name).to.equal('click') - expect(instance.logs[0].selector).to.equal('.previous-selector') - }) - - it('clears previous mouse event after recording any event', () => { - const el = $('
    ')[0] - - instance._previousMouseEvent = { - selector: '.previous-selector', - element: $('')[0], - } - - instance._recordEvent(createEvent({ type: 'click', target: el })) - - expect(instance._previousMouseEvent).to.be.null - }) - - it('records a clear event before recording a type event', () => { - const $el = $('') - - instance._recordEvent(createEvent({ type: 'keyup', key: 'l', target: $el })) - - expect(instance.logs.length).to.equal(2) - expect(instance.logs[0].name).to.equal('clear') - expect(instance.logs[0].message).to.equal(null) - expect(instance.logs[1].name).to.equal('type') - expect(instance.logs[1].message).to.equal('val') - }) - - it('removes an existing type if additional typing causes element to become empty', () => { - instance.logs = [{ - id: 1, - selector: '.selector', - name: 'clear', - message: null, - }, { - id: 2, - selector: '.selector', - name: 'type', - message: 'a', - }] - - const $el = $('') - - instance._recordEvent(createEvent({ type: 'keyup', key: 'Backspace', target: $el })) - - expect(instance.logs.length).to.equal(1) - expect(instance.logs[0].name).to.equal('clear') - expect(instance.logs[0].message).to.equal(null) - }) - - it('does not record a duplicate clear event if one already exists when typing', () => { - instance.logs = [{ - id: 1, - selector: '.selector', - name: 'clear', - message: null, - }] - - const $el = $('') - - instance._recordEvent(createEvent({ type: 'keyup', key: 'l', target: $el })) - - expect(instance.logs.length).to.equal(2) - expect(instance.logs[0].name).to.equal('clear') - expect(instance.logs[0].message).to.equal(null) - expect(instance.logs[1].name).to.equal('type') - expect(instance.logs[1].message).to.equal('val') - }) - - it('does not record keyup outside of input', () => { - const $el = $('
    ') - - instance._recordEvent(createEvent({ type: 'keyup', key: 'a', target: $el })) - - expect(instance.logs).to.be.empty - }) - - it('does not record unneeded change events', () => { - const $el = $('') - - instance._recordEvent(createEvent({ type: 'change', target: $el })) - - expect(instance.logs).to.be.empty - }) - - it('does not record keyup for enter key', () => { - const $el = $('') - - instance._recordEvent(createEvent({ type: 'keyup', key: 'Enter', target: $el })) - - expect(instance.logs).to.be.empty - }) - - it('only records keydown for enter key', () => { - const $el = $('') - - instance._recordEvent(createEvent({ type: 'keydown', key: 'a', target: $el })) - - expect(instance.logs).to.be.empty - - $el.val('a') - instance._recordEvent(createEvent({ type: 'keydown', key: 'b', target: $el })) - - expect(instance.logs).to.be.empty - - $el.val('ab') - instance._recordEvent(createEvent({ type: 'keydown', key: 'Enter', target: $el })) - - expect(instance.logs[1].name).to.equal('type') - expect(instance.logs[1].message).to.equal('ab{enter}') - }) - - it('records multi select changes', () => { - const $el = $('') - - $el.val(['0', '1']) - - instance._recordEvent(createEvent({ type: 'change', target: $el })) - - expect(instance.logs[0].name).to.eql('select') - expect(instance.logs[0].message).to.eql(['0', '1']) - }) - - it('does not record events on