From 66387f0a4176e5d6829bed0334d81e60460c3341 Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Thu, 4 Aug 2022 15:14:54 +1000 Subject: [PATCH 01/37] wip --- packages/app/src/runner/event-manager.ts | 13 +- packages/app/src/store/studio-store.ts | 868 +++++++++++++++++++++++ packages/config/src/options.ts | 11 - packages/driver/cypress.config.ts | 1 + packages/driver/cypress/e2e/spec.cy.ts | 4 + packages/reporter/src/lib/app-state.ts | 1 + packages/reporter/src/lib/events.ts | 1 + packages/reporter/src/main.tsx | 4 +- packages/reporter/src/test/test.tsx | 16 +- 9 files changed, 894 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/store/studio-store.ts create mode 100644 packages/driver/cypress/e2e/spec.cy.ts diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 5b8be0ed6ae1..9fa489c00a5c 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -278,21 +278,24 @@ export class EventManager { const studioInit = () => { this.ws.emit('studio:init', (showedStudioModal) => { - if (!showedStudioModal) { - this.studioRecorder.showInitModal() - } else { + // if (!showedStudioModal) { + // this.studioRecorder.showInitModal() + // } else { + console.log('re run!') rerun() - } + // } }) } this.reporterBus.on('studio:init:test', (testId) => { + console.log('init studio test', testId) this.studioRecorder.setTestId(testId) studioInit() }) this.reporterBus.on('studio:init:suite', (suiteId) => { + console.log('init studio suite') this.studioRecorder.setSuiteId(suiteId) studioInit() @@ -696,12 +699,14 @@ export class EventManager { } _runDriver (state) { + console.log('Run driver') performance.mark('run-s') Cypress.run(() => { performance.mark('run-e') performance.measure('run', 'run-s', 'run-e') }) + console.log(this.studioRecorder) this.reporterBus.emit('reporter:start', { startTime: Cypress.runner.getStartTime(), numPassed: state.passed, diff --git a/packages/app/src/store/studio-store.ts b/packages/app/src/store/studio-store.ts new file mode 100644 index 000000000000..4f042fd38563 --- /dev/null +++ b/packages/app/src/store/studio-store.ts @@ -0,0 +1,868 @@ +import { defineStore } from 'pinia' + +import { action, computed, observable } from 'mobx' +// import $ from 'jquery' +// import $driverUtils from '@packages/driver/src/cypress/utils' +// import { dom } from '../dom' +// import { eventManager } from '../event-manager' + +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', +] + +interface StudioLog { + id: string + selector: string + name: string + message: string +} + +interface StudioRecorderState { + initModalIsOpen: boolean + saveModalIsOpen: boolean + logs: StudioLog[] + isLoading: boolean + isActive: boolean + isFailed: boolean + _hasStarted: boolean + + testId?: string + suiteId?: string + url?: string + + fileDetails?: string + absoluteFile?: string + runnableTitle?: string + _previousMouseEvent?: string + + _currentId: number +} + +export const useStudioRecorderStore = defineStore('studioRecorder', { + state: (): StudioRecorderState => ({ + initModalIsOpen: false, + saveModalIsOpen: false, + logs: [], + isLoading: false, + isActive: false, + isFailed: false, + _hasStarted: false, + _currentId: 1 + }) +}) + +export class StudioRecorder { + @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 eventManager.getCypress() + } + + saveError (err) { + return { + id: this.testId, + err: { + ...err, + message: saveErrorMessage(err.message), + docsUrl: 'https://on.cypress.io/studio-beta', + }, + } + } + + @action setTestId = (testId) => { + console.log('Set test id', testId) + this.testId = testId + } + + @action setSuiteId = (suiteId) => { + console.log('Set suite id', 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) => { + console.log('start!!') + 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() + + 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) => { + 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) => { + 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 + + 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) => { + 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 + } +} + +export const studioRecorder = new StudioRecorder() diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index c05be50a02ea..1c91e2cefd6d 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -19,7 +19,6 @@ export type BreakingOptionErrorKey = | 'EXPERIMENTAL_RUN_EVENTS_REMOVED' | 'EXPERIMENTAL_SESSION_SUPPORT_REMOVED' | 'EXPERIMENTAL_SHADOW_DOM_REMOVED' - | 'EXPERIMENTAL_STUDIO_REMOVED' | 'FIREFOX_GC_INTERVAL_REMOVED' | 'NODE_VERSION_DEPRECATION_SYSTEM' | 'NODE_VERSION_DEPRECATION_BUNDLED' @@ -604,11 +603,6 @@ export const breakingOptions: Array = [ 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', @@ -660,11 +654,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/driver/cypress.config.ts b/packages/driver/cypress.config.ts index ffacf3c4d442..f555c0fd7527 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'cypress' export default defineConfig({ 'projectId': 'ypt4pf', + 'experimentalStudio': true, 'hosts': { '*.foobar.com': '127.0.0.1', '*.idp.com': '127.0.0.1', diff --git a/packages/driver/cypress/e2e/spec.cy.ts b/packages/driver/cypress/e2e/spec.cy.ts new file mode 100644 index 000000000000..bdd67196720f --- /dev/null +++ b/packages/driver/cypress/e2e/spec.cy.ts @@ -0,0 +1,4 @@ +describe('empty spec', () => { + it('passes', () => { + }) +}) \ No newline at end of file diff --git a/packages/reporter/src/lib/app-state.ts b/packages/reporter/src/lib/app-state.ts index 2ccd675a92e0..5682046091c4 100644 --- a/packages/reporter/src/lib/app-state.ts +++ b/packages/reporter/src/lib/app-state.ts @@ -89,6 +89,7 @@ class AppState { } setStudioActive (studioActive: boolean) { + console.log(`Set Active ${studioActive}`) this.studioActive = studioActive } diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 0b819c765e77..ee36e0407685 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -97,6 +97,7 @@ const events: Events = { appState.temporarilySetAutoScrolling(startInfo.autoScrollingEnabled) runnablesStore.setInitialScrollTop(startInfo.scrollTop) appState.setStudioActive(startInfo.studioActive) + console.log(startInfo.studioActive) if (runnablesStore.hasTests) { statsStore.start(startInfo) } diff --git a/packages/reporter/src/main.tsx b/packages/reporter/src/main.tsx index 22756a2d2d07..bdc658d96667 100644 --- a/packages/reporter/src/main.tsx +++ b/packages/reporter/src/main.tsx @@ -64,9 +64,11 @@ class Reporter extends Component { renderReporterHeader = (props: ReporterHeaderProps) =>
, } = this.props + console.log({experimentalStudioEnabled}) + return (
{renderReporterHeader({ appState, statsStore })} diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 5db23ad94cc8..3291af0ead98 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -192,15 +192,13 @@ class Test extends Component { ) } - if (appState.studioActive) { - controls.push( - - - - - , - ) - } + controls.push( + + + + + , + ) if (controls.length === 0) { return null From 1131c736c4187ce05a919be72ebedf927b658d4d Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 5 Aug 2022 14:38:46 +1000 Subject: [PATCH 02/37] wip --- .../src/runner/SpecRunnerHeaderOpenMode.vue | 10 + packages/app/src/runner/aut-iframe.ts | 18 +- packages/app/src/runner/event-manager.ts | 14 +- packages/app/src/runner/index.ts | 6 +- packages/app/src/runner/useEventManager.ts | 4 + packages/app/src/store/studio-store.ts | 1208 ++++++------ .../src/gql-components/HeaderBar.vue | 1 + packages/reporter/src/attempts/attempts.tsx | 1 + packages/runner-ct/unified-runner.tsx | 4 +- .../src/studio/studio-recorder.js | 1672 ++++++++--------- 10 files changed, 1487 insertions(+), 1451 deletions(-) diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue index 82b7c7d0b703..5131cec15ef2 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue @@ -13,6 +13,8 @@ 'bg-gray-50': autStore.isLoadingUrl }" > + + - -
- You need to enter a URL! -
-
+ + { }) const autUrl = computed(() => { - if (studioRecorder.isActive && studioRecorder.url) { - return studioRecorder.url + if (studioStore.isActive && studioStore.url) { + return studioStore.url } return autStore.url @@ -242,27 +236,17 @@ const activeSpecPath = specStore.activeSpec?.absolute const isDisabled = computed(() => autStore.isRunning || autStore.isLoading) -const studioRecorder = useStudioRecorderStore() - function setStudioUrl (event: Event) { const url = (event.currentTarget as HTMLInputElement).value - studioRecorder.setUrl(url) + studioStore.setUrl(url) } function openInNewTab () { - if (!autStore.url || studioRecorder.isActive) { + if (!autStore.url || studioStore.isActive) { return } window.open(autStore.url, '_blank')?.focus() } - -function visitUrl () { - if (!studioRecorder.url) { - throw Error('Cannot visit blank url') - } - - studioRecorder.visitUrl(studioRecorder.url) -} diff --git a/packages/app/src/runner/StudioControls.vue b/packages/app/src/runner/StudioControls.vue new file mode 100644 index 000000000000..a4d126b4df85 --- /dev/null +++ b/packages/app/src/runner/StudioControls.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/app/src/runner/event-manager-types.ts b/packages/app/src/runner/event-manager-types.ts index 15a74b6b6d9c..bc18e9e90839 100644 --- a/packages/app/src/runner/event-manager-types.ts +++ b/packages/app/src/runner/event-manager-types.ts @@ -32,9 +32,17 @@ export interface StudioSavePayload { } 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 diff --git a/packages/app/src/store/studio-store.ts b/packages/app/src/store/studio-store.ts index d05090969f74..d35a316383d5 100644 --- a/packages/app/src/store/studio-store.ts +++ b/packages/app/src/store/studio-store.ts @@ -119,6 +119,7 @@ export const useStudioRecorderStore = defineStore('studioRecorder', { initModalIsOpen: false, saveModalIsOpen: false, logs: [], + url: '', isLoading: false, isActive: false, isFailed: false, From 55e3a319b64cfdb9a19ed65740059accf502735a Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 18:52:51 +1000 Subject: [PATCH 15/37] fixing tests --- packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts | 2 +- packages/app/cypress/e2e/spec.cy.ts | 9 --------- packages/driver/cypress/e2e/spec.cy.ts | 4 ---- 3 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 packages/app/cypress/e2e/spec.cy.ts delete mode 100644 packages/driver/cypress/e2e/spec.cy.ts 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/spec.cy.ts b/packages/app/cypress/e2e/spec.cy.ts deleted file mode 100644 index 5ce52b075e08..000000000000 --- a/packages/app/cypress/e2e/spec.cy.ts +++ /dev/null @@ -1,9 +0,0 @@ -describe('empty spec', () => { - it('passes', () => { - /* ==== Generated with Cypress Studio ==== */ - cy.visit('http://lmiller1990.github.io') - cy.get('.center').click() - cy.get('.resume > :nth-child(3) > :nth-child(1)').click() - /* ==== End Cypress Studio ==== */ - }) -}) diff --git a/packages/driver/cypress/e2e/spec.cy.ts b/packages/driver/cypress/e2e/spec.cy.ts deleted file mode 100644 index bdd67196720f..000000000000 --- a/packages/driver/cypress/e2e/spec.cy.ts +++ /dev/null @@ -1,4 +0,0 @@ -describe('empty spec', () => { - it('passes', () => { - }) -}) \ No newline at end of file From 08d023f722dbb336eee2212df5b342788beb258e Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 18:54:21 +1000 Subject: [PATCH 16/37] revert --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 85d31d8abd5d..f651d34a4f42 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,7 +38,7 @@ // Volar is the main extension that powers Vue's language features. // These are commented out because they slow down node development // "volar.autoCompleteRefs": false, - // "volar.takeOverMode.enabled": true, + "volar.takeOverMode.enabled": true, "editor.tabSize": 2, } From 62a4f51ce112a298b493da7b1fc47e949bef3ca2 Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 18:55:33 +1000 Subject: [PATCH 17/37] revert changes --- packages/app/cypress/e2e/support/e2eSupport.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/cypress/e2e/support/e2eSupport.ts b/packages/app/cypress/e2e/support/e2eSupport.ts index 5b4822ea4de7..f3f41da83858 100644 --- a/packages/app/cypress/e2e/support/e2eSupport.ts +++ b/packages/app/cypress/e2e/support/e2eSupport.ts @@ -2,9 +2,9 @@ import '@packages/frontend-shared/cypress/e2e/support/e2eSupport' import 'cypress-real-events/support' import './execute-spec' -// beforeEach(() => { -// this is always 0, since we only destroy the AUT when using -// `experimentalSingleTabRunMode, which is not a valid experiment for for e2e testing. -// @ts-ignore - dynamically defined during tests using -// expect(window.top?.getEventManager().autDestroyedCount).to.be.undefined -// }) +beforeEach(() => { + // this is always 0, since we only destroy the AUT when using + // `experimentalSingleTabRunMode, which is not a valid experiment for for e2e testing. + // @ts-ignore - dynamically defined during tests using + expect(window.top?.getEventManager().autDestroyedCount).to.be.undefined +}) From be9a80171ee402ae8befd7a5e1bd35f21d5d867e Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 19:03:02 +1000 Subject: [PATCH 18/37] rename store --- packages/app/src/runner/StudioControls.vue | 99 +++ packages/app/src/store/studio-store.ts | 907 +++++++++++++++++++++ 2 files changed, 1006 insertions(+) create mode 100644 packages/app/src/runner/StudioControls.vue create mode 100644 packages/app/src/store/studio-store.ts diff --git a/packages/app/src/runner/StudioControls.vue b/packages/app/src/runner/StudioControls.vue new file mode 100644 index 000000000000..8de85c295576 --- /dev/null +++ b/packages/app/src/runner/StudioControls.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/app/src/store/studio-store.ts b/packages/app/src/store/studio-store.ts new file mode 100644 index 000000000000..a83dccf72530 --- /dev/null +++ b/packages/app/src/store/studio-store.ts @@ -0,0 +1,907 @@ +import type { 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?: string + 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.fileDetails, `fileDetails should exist!`) + assertNonNullish(this.absoluteFile, `absoluteFile should exist`) + assertNonNullish(this.runnableTitle, `runnableTitle 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) { + 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', + } + }, + }, +}) From dd435fa207f1a371805d485866911f1fa97fb348 Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 19:07:29 +1000 Subject: [PATCH 19/37] rename method --- packages/app/src/runner/SpecRunnerHeaderOpenMode.vue | 4 ++-- packages/app/src/runner/aut-iframe.ts | 6 +++--- packages/app/src/runner/event-manager.ts | 6 +++--- packages/app/src/runner/index.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue index 59a60ad91897..9cc972d7c953 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue @@ -170,7 +170,7 @@ import InlineCodeFragment from '@packages/frontend-shared/src/components/InlineC import SpecRunnerDropdown from './SpecRunnerDropdown.vue' import { allBrowsersIcons } from '@packages/frontend-shared/src/assets/browserLogos' import BookIcon from '~icons/cy/book_x16' -import { useStudioRecorderStore } from '../store/studio-store' +import { useStudioStore } from '../store/studio-store' gql` fragment SpecRunnerHeader on CurrentProject { @@ -195,7 +195,7 @@ const specStore = useSpecStore() const route = useRoute() -const studioStore = useStudioRecorderStore() +const studioStore = useStudioStore() const props = defineProps<{ gql: SpecRunnerHeaderFragment diff --git a/packages/app/src/runner/aut-iframe.ts b/packages/app/src/runner/aut-iframe.ts index 2a3619cbb1dd..20faeed51df7 100644 --- a/packages/app/src/runner/aut-iframe.ts +++ b/packages/app/src/runner/aut-iframe.ts @@ -4,7 +4,7 @@ import { logger } from './logger' import _ from 'lodash' /* eslint-disable no-duplicate-imports */ import type { DebouncedFunc } from 'lodash' -import { useStudioRecorderStore } from '../store/studio-store' +import { useStudioStore } from '../store/studio-store' // JQuery bundled w/ Cypress type $CypressJQuery = any @@ -482,7 +482,7 @@ export class AutIframe { } startStudio () { - const studioRecorder = useStudioRecorderStore() + const studioRecorder = useStudioStore() if (studioRecorder.isLoading) { studioRecorder.start(this._body()?.[0]) @@ -490,7 +490,7 @@ export class AutIframe { } reattachStudio () { - const studioRecorder = useStudioRecorderStore() + const studioRecorder = useStudioStore() if (studioRecorder.isActive) { const body = this._body()?.[0] diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 558c3a59a6f0..a2b9a63ab528 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -11,7 +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 { useStudioRecorderStore } from '../store/studio-store' +import { useStudioStore } from '../store/studio-store' import { getAutIframeModel } from '.' export type CypressInCypressMochaEvent = Array>> @@ -53,7 +53,7 @@ export class EventManager { reporterBus: EventEmitter = new EventEmitter() localBus: EventEmitter = new EventEmitter() Cypress?: $Cypress - studioRecorder: ReturnType + studioRecorder: ReturnType selectorPlaygroundModel: any cypressInCypressMochaEvents: CypressInCypressMochaEvent[] = [] // Used for testing the experimentalSingleTabRunMode experiment. Ensures AUT is correctly destroyed between specs. @@ -70,7 +70,7 @@ export class EventManager { StudioRecorderCtor: any, ws: Socket, ) { - this.studioRecorder = useStudioRecorderStore() // new StudioRecorderCtor(this) + this.studioRecorder = useStudioStore() // new StudioRecorderCtor(this) this.selectorPlaygroundModel = selectorPlaygroundModel this.ws = ws } diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index 9c80f46279a5..faadc10d81b8 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -26,7 +26,7 @@ import { client } from '@packages/socket/lib/browser' import { decodeBase64Unicode } from '@packages/frontend-shared/src/utils/base64' import type { AutomationElementId } from '@packages/types/src' import { useSnapshotStore } from './snapshot-store' -import { useStudioRecorderStore } from '../store/studio-store' +import { useStudioStore } from '../store/studio-store' let _eventManager: EventManager | undefined @@ -60,7 +60,7 @@ export function initializeEventManager (UnifiedRunner: any) { UnifiedRunner.CypressDriver, UnifiedRunner.MobX, UnifiedRunner.selectorPlaygroundModel, - useStudioRecorderStore(), + useStudioStore(), // UnifiedRunner.StudioRecorder, // created once when opening runner at the very top level in main.ts window.ws, From 568a7f6777f3c067dec3c5dbce92bcda2a67cd86 Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 19:07:49 +1000 Subject: [PATCH 20/37] remove comment --- packages/app/src/runner/event-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index a2b9a63ab528..1c4ac4fc7ea8 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -70,7 +70,7 @@ export class EventManager { StudioRecorderCtor: any, ws: Socket, ) { - this.studioRecorder = useStudioStore() // new StudioRecorderCtor(this) + this.studioRecorder = useStudioStore() this.selectorPlaygroundModel = selectorPlaygroundModel this.ws = ws } From 40327d84bfc92b05d2043a83880c5c2b8967f829 Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 19:12:20 +1000 Subject: [PATCH 21/37] refactor --- .../cypress/component/support/ctSupport.ts | 3 - packages/app/src/runner/event-manager.ts | 62 +++++++++---------- packages/app/src/runner/index.ts | 5 +- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/packages/app/cypress/component/support/ctSupport.ts b/packages/app/cypress/component/support/ctSupport.ts index a24058fc4e6a..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, ) } diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 1c4ac4fc7ea8..8afcf8876efb 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -53,7 +53,6 @@ export class EventManager { reporterBus: EventEmitter = new EventEmitter() localBus: EventEmitter = new EventEmitter() Cypress?: $Cypress - studioRecorder: ReturnType selectorPlaygroundModel: any cypressInCypressMochaEvents: CypressInCypressMochaEvent[] = [] // Used for testing the experimentalSingleTabRunMode experiment. Ensures AUT is correctly destroyed between specs. @@ -66,11 +65,8 @@ export class EventManager { private Mobx: typeof MobX, // selectorPlaygroundModel singleton selectorPlaygroundModel: any, - // StudioRecorder constructor - StudioRecorderCtor: any, ws: Socket, ) { - this.studioRecorder = useStudioStore() this.selectorPlaygroundModel = selectorPlaygroundModel this.ws = ws } @@ -151,7 +147,7 @@ export class EventManager { }) this.ws.on('watched:file:changed', () => { - this.studioRecorder.cancel() + useStudioStore().cancel() rerun() }) @@ -284,37 +280,37 @@ export class EventManager { const studioInit = () => { this.ws.emit('studio:init', (showedStudioModal) => { rerun() - // if (!showedStudioModal) { - // this.studioRecorder.showInitModal() - // } else { - // rerun() - // } + if (!showedStudioModal) { + useStudioStore().showInitModal() + } else { + rerun() + } }) } this.reporterBus.on('studio:init:test', (testId) => { - this.studioRecorder.setTestId(testId) + useStudioStore().setTestId(testId) studioInit() }) this.reporterBus.on('studio:init:suite', (suiteId) => { - this.studioRecorder.setSuiteId(suiteId) + useStudioStore().setSuiteId(suiteId) studioInit() }) this.reporterBus.on('studio:cancel', () => { - this.studioRecorder.cancel() + useStudioStore().cancel() rerun() }) this.reporterBus.on('studio:remove:command', (commandId) => { - this.studioRecorder.removeLog(commandId) + useStudioStore().removeLog(commandId) }) this.reporterBus.on('studio:save', () => { - this.studioRecorder.startSave() + useStudioStore().startSave() }) this.reporterBus.on('studio:copy:to:clipboard', (cb) => { @@ -322,7 +318,7 @@ export class EventManager { }) this.localBus.on('studio:start', () => { - this.studioRecorder.closeInitModal() + useStudioStore().closeInitModal() rerun() }) @@ -333,13 +329,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', useStudioStore().saveError(err), noop) } }) }) this.localBus.on('studio:cancel', () => { - this.studioRecorder.cancel() + useStudioStore().cancel() rerun() }) @@ -416,7 +412,7 @@ export class EventManager { return } - this.studioRecorder.initialize(config, state) + useStudioStore().initialize(config, state) const runnables = Cypress.runner.normalizeAll(state.tests) @@ -477,9 +473,9 @@ export class EventManager { resolve({ ...reporterState, studio: { - testId: this.studioRecorder.testId, - suiteId: this.studioRecorder.suiteId, - url: this.studioRecorder.url, + testId: useStudioStore().testId, + suiteId: useStudioStore().suiteId, + url: useStudioStore().url, }, }) }) @@ -598,12 +594,12 @@ export class EventManager { }) Cypress.on('test:before:run:async', (_attr, test) => { - this.studioRecorder.interceptTest(test) + useStudioStore().interceptTest(test) }) Cypress.on('test:after:run', (test) => { - if (this.studioRecorder.isOpen && test.state !== 'passed') { - this.studioRecorder.testFailed() + if (useStudioStore().isOpen && test.state !== 'passed') { + useStudioStore().testFailed() } }) @@ -740,7 +736,7 @@ export class EventManager { performance.measure('run', 'run-s', 'run-e') }) - const hasRunnableId = !!this.studioRecorder.testId || !!this.studioRecorder.suiteId + const hasRunnableId = !!useStudioStore().testId || !!useStudioStore().suiteId this.reporterBus.emit('reporter:start', { startTime: Cypress.runner.getStartTime(), @@ -778,7 +774,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() + useStudioStore().setInactive() } resetReporter () { @@ -804,12 +800,12 @@ export class EventManager { } _interceptStudio (displayProps) { - if (this.studioRecorder.isActive) { - displayProps.hookId = this.studioRecorder.hookId + if (useStudioStore().isActive) { + displayProps.hookId = useStudioStore().hookId if (displayProps.name === 'visit' && displayProps.state === 'failed') { - this.studioRecorder.testFailed() - this.reporterBus.emit('test:set:state', this.studioRecorder.testError, noop) + useStudioStore().testFailed() + this.reporterBus.emit('test:set:state', useStudioStore().testError, noop) } } @@ -817,8 +813,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', useStudioStore().logs, (commandsText) => { + useStudioStore().copyToClipboard(commandsText) .then(cb) }) } diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index faadc10d81b8..d0e26e3fd70e 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -60,9 +60,6 @@ export function initializeEventManager (UnifiedRunner: any) { UnifiedRunner.CypressDriver, UnifiedRunner.MobX, UnifiedRunner.selectorPlaygroundModel, - useStudioStore(), - // UnifiedRunner.StudioRecorder, - // created once when opening runner at the very top level in main.ts window.ws, ) } @@ -111,7 +108,7 @@ function createIframeModel () { autIframe.doesAUTMatchTopOriginPolicy, getEventManager(), { - recorder: getEventManager().studioRecorder, + recorder: useStudioStore(), selectorPlaygroundModel: getEventManager().selectorPlaygroundModel, }, ) From b0299c38009792df98ecd706dab29c04f24490dc Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Fri, 19 Aug 2022 20:08:30 +1000 Subject: [PATCH 22/37] correctly feature flag studio --- packages/app/src/main.ts | 5 ++--- packages/app/src/runner/event-manager.ts | 4 ++-- packages/app/src/runner/index.ts | 2 +- packages/app/src/runner/reporter.ts | 7 +++++-- packages/driver/cypress.config.ts | 2 +- packages/reporter/src/main.tsx | 4 ++-- packages/reporter/src/test/test.tsx | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) 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/event-manager.ts b/packages/app/src/runner/event-manager.ts index 8afcf8876efb..6844615d8985 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -279,7 +279,6 @@ export class EventManager { const studioInit = () => { this.ws.emit('studio:init', (showedStudioModal) => { - rerun() if (!showedStudioModal) { useStudioStore().showInitModal() } else { @@ -736,7 +735,8 @@ export class EventManager { performance.measure('run', 'run-s', 'run-e') }) - const hasRunnableId = !!useStudioStore().testId || !!useStudioStore().suiteId + const studioStore = useStudioStore() + const hasRunnableId = !!studioStore.testId || !!studioStore.suiteId this.reporterBus.emit('reporter:start', { startTime: Cypress.runner.getStartTime(), diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index d0e26e3fd70e..e09cb0aa7089 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -343,7 +343,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..8f1b037f792d 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,6 +39,8 @@ function renderReporter ( ) { const runnerUiStore = useRunnerUiStore() + const config = getRunnerConfigFromWindow() + const reporter = window.UnifiedRunner.React.createElement(window.UnifiedRunner.Reporter, { runMode: 'single' as const, runner: eventManager.reporterBus, @@ -46,7 +48,8 @@ function renderReporter ( isSpecsListOpen: runnerUiStore.isSpecsListOpen, error: null, // errorMessages.reporterError(props.state.scriptError, props.state.spec.relative), resetStatsOnSpecChange: true, - experimentalStudioEnabled: false, + // @ts-ignore - https://github.com/cypress-io/cypress/issues/23338 + experimentalStudioEnabled: config.experimentalStudio, runnerStore: store, }) diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index ec5ec670d86b..1340a1e60793 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'cypress' export default defineConfig({ 'projectId': 'ypt4pf', - // @ts-ignore - need to define this + // @ts-ignore - https://github.com/cypress-io/cypress/issues/23338 'experimentalStudio': true, 'hosts': { '*.foobar.com': '127.0.0.1', diff --git a/packages/reporter/src/main.tsx b/packages/reporter/src/main.tsx index fd2eb38c3905..22756a2d2d07 100644 --- a/packages/reporter/src/main.tsx +++ b/packages/reporter/src/main.tsx @@ -60,13 +60,13 @@ class Reporter extends Component { scroller, error, statsStore, - // experimentalStudioEnabled, + experimentalStudioEnabled, renderReporterHeader = (props: ReporterHeaderProps) =>
, } = this.props return (
{renderReporterHeader({ appState, statsStore })} diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index e2accec77853..3291af0ead98 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -217,7 +217,7 @@ class Test extends Component { return (
this._scrollIntoView()} /> - + {appState.studioActive && }
) } From 933932b1db4b473f52ec364f804b18fecfb82843 Mon Sep 17 00:00:00 2001 From: astone123 Date: Fri, 19 Aug 2022 16:25:52 -0400 Subject: [PATCH 23/37] chore: wip add barebones studio modals --- .../app/src/runner/SpecRunnerOpenMode.vue | 20 +++ packages/app/src/runner/StudioControls.vue | 6 +- packages/app/src/runner/reporter.ts | 6 +- .../src/runner/studio/StudioInitModal.cy.tsx | 24 ++++ .../app/src/runner/studio/StudioInitModal.vue | 52 +++++++ .../studio/StudioInstructionsModal.cy.tsx | 14 ++ .../runner/studio/StudioInstructionsModal.vue | 70 +++++++++ .../src/runner/studio/StudioSaveModal.cy.tsx | 25 ++++ .../app/src/runner/studio/StudioSaveModal.vue | 67 +++++++++ packages/app/src/static/studio.gif | Bin 0 -> 395863 bytes packages/app/src/store/studio-store.ts | 133 +++++++++++++++++- .../frontend-shared/src/locales/en-US.json | 12 ++ packages/reporter/src/lib/app-state.ts | 10 ++ packages/reporter/src/main.tsx | 2 + .../src/runnables/runnable-and-suite.tsx | 12 +- packages/reporter/src/runnables/runnables.tsx | 16 ++- packages/reporter/src/test/test.tsx | 19 +-- 17 files changed, 460 insertions(+), 28 deletions(-) create mode 100644 packages/app/src/runner/studio/StudioInitModal.cy.tsx create mode 100644 packages/app/src/runner/studio/StudioInitModal.vue create mode 100644 packages/app/src/runner/studio/StudioInstructionsModal.cy.tsx create mode 100644 packages/app/src/runner/studio/StudioInstructionsModal.vue create mode 100644 packages/app/src/runner/studio/StudioSaveModal.cy.tsx create mode 100644 packages/app/src/runner/studio/StudioSaveModal.vue create mode 100644 packages/app/src/static/studio.gif diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index efa65c8d5298..50f1d3da1849 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -1,4 +1,18 @@ From ee520414bd3f23176ed02628a61bd5f4cf694292 Mon Sep 17 00:00:00 2001 From: astone123 Date: Tue, 23 Aug 2022 19:10:52 -0400 Subject: [PATCH 32/37] chore: studio URL prompt and other changes --- .../src/runner/SpecRunnerHeaderOpenMode.vue | 31 +++++++- packages/app/src/runner/reporter.ts | 4 +- .../runner/{ => studio}/StudioControls.vue | 28 ++----- .../src/runner/studio/StudioUrlPrompt.cy.tsx | 28 +++++++ .../app/src/runner/studio/StudioUrlPrompt.vue | 78 +++++++++++++++++++ packages/app/src/store/studio-store.ts | 9 +++ .../frontend-shared/src/locales/en-US.json | 5 +- 7 files changed, 155 insertions(+), 28 deletions(-) rename packages/app/src/runner/{ => studio}/StudioControls.vue (89%) create mode 100644 packages/app/src/runner/studio/StudioUrlPrompt.cy.tsx create mode 100644 packages/app/src/runner/studio/StudioUrlPrompt.vue diff --git a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue index 821e802c576c..f468b41e581e 100644 --- a/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerHeaderOpenMode.vue @@ -24,13 +24,21 @@ +
() + const props = defineProps<{ gql: SpecRunnerHeaderFragment eventManager: EventManager @@ -239,7 +252,17 @@ const isDisabled = computed(() => autStore.isRunning || autStore.isLoading) function setStudioUrl (event: Event) { const url = (event.currentTarget as HTMLInputElement).value - studioStore.setUrl(url) + urlInProgress.value = url +} + +function visitUrl () { + studioStore.setUrl(urlInProgress.value) + + if (!studioStore.url) { + throw Error('Cannot visit blank url') + } + + studioStore.visitUrl(studioStore.url) } function openInNewTab () { diff --git a/packages/app/src/runner/reporter.ts b/packages/app/src/runner/reporter.ts index e36487a7d9b4..9bd76525c84f 100644 --- a/packages/app/src/runner/reporter.ts +++ b/packages/app/src/runner/reporter.ts @@ -48,8 +48,8 @@ function renderReporter ( isSpecsListOpen: runnerUiStore.isSpecsListOpen, error: null, resetStatsOnSpecChange: true, - // @ts-ignore - https://github.com/cypress-io/cypress/issues/23338 - studioEnabled: config.experimentalStudio, + // Studio can only be enabled for e2e testing + studioEnabled: window.__CYPRESS_TESTING_TYPE__ === 'e2e' && config.experimentalStudio, runnerStore: store, }) diff --git a/packages/app/src/runner/StudioControls.vue b/packages/app/src/runner/studio/StudioControls.vue similarity index 89% rename from packages/app/src/runner/StudioControls.vue rename to packages/app/src/runner/studio/StudioControls.vue index cf1ece8d1f2d..f400727f1105 100644 --- a/packages/app/src/runner/StudioControls.vue +++ b/packages/app/src/runner/studio/StudioControls.vue @@ -1,19 +1,9 @@