misc: (studio) add support for url routing (#31205)

* update url with studio params

* updates

* spec updates

* adding tests

* updating changelog

* skip adding visit log during start

* update url support

* cy origin tests

* fix tests

* updates

* update origin test

* add a wait

* update

* pr updates

---------

Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
This commit is contained in:
Matt Schile
2025-03-10 21:36:18 -06:00
committed by GitHub
parent 5ff7e37868
commit b9103af3fd
19 changed files with 1118 additions and 170 deletions

View File

@@ -10,6 +10,7 @@ _Released 3/11/2025 (PENDING)_
**Misc:**
- Additional CLI options will be displayed in the terminal for some Cloud error messages. Addressed in [#31211](https://github.com/cypress-io/cypress/pull/31211).
- Updated Cypress Studio with url routing to support maintaining state when reloading. Addresses [#31000](https://github.com/cypress-io/cypress/issues/31000) and [#30996](https://github.com/cypress-io/cypress/issues/30996).
**Dependency Updates:**

View File

@@ -26,9 +26,13 @@ export default defineConfig({
framework: 'vue',
},
},
hosts: {
'foobar.com': '127.0.0.1',
},
'e2e': {
experimentalRunAllSpecs: true,
experimentalStudio: true,
experimentalOriginDependencies: true,
baseUrl: 'http://localhost:5555',
supportFile: 'cypress/e2e/support/e2eSupport.ts',
async setupNodeEvents (on, config) {

View File

@@ -1,25 +1,36 @@
export function launchStudio () {
export function launchStudio ({ specName = 'spec.cy.js', createNewTest = false, cliArgs = [''] } = {}) {
cy.scaffoldProject('experimental-studio')
cy.openProject('experimental-studio')
cy.openProject('experimental-studio', cliArgs)
cy.startAppServer('e2e')
cy.visitApp()
cy.specsPageIsVisible()
cy.get(`[data-cy-row="spec.cy.js"]`).click()
cy.get(`[data-cy-row="${specName}"]`).click()
cy.waitForSpecToFinish()
// Should not show "Studio Commands" until we've started a new Studio session.
cy.get('[data-cy="hook-name-studio commands"]').should('not.exist')
cy
.contains('visits a basic html page')
.closest('.runnable-wrapper')
if (createNewTest) {
cy.contains('studio functionality').as('item')
} else {
cy.contains('visits a basic html page').as('item')
}
cy.get('@item')
.closest('.runnable-wrapper').as('runnable-wrapper')
.realHover()
cy.get('@runnable-wrapper')
.findByTestId('launch-studio')
.click()
// Studio re-executes spec before waiting for commands - wait for the spec to finish executing.
cy.waitForSpecToFinish()
cy.get('[data-cy="hook-name-studio commands"]').should('exist')
if (createNewTest) {
cy.get('span.runnable-title').contains('New Test').should('exist')
} else {
cy.get('[data-cy="hook-name-studio commands"]').should('exist')
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -315,6 +315,8 @@ export class EventManager {
this.ws.emit('studio:save', saveInfo, (err) => {
if (err) {
this.reporterBus.emit('test:set:state', this.studioStore.saveError(err), noop)
} else {
this.studioStore.saveSuccess()
}
})
})
@@ -419,7 +421,7 @@ export class EventManager {
const hideCommandLog = Cypress.config('hideCommandLog')
this.studioStore.initialize(config, runState)
this.studioStore.initialize(config)
const runnables = Cypress.runner.normalizeAll(runState.tests, hideCommandLog, testFilter)
@@ -485,14 +487,7 @@ export class EventManager {
return new Bluebird((resolve) => {
this.reporterBus.emit('reporter:collect:run:state', (reporterState: ReporterRunState) => {
resolve({
...reporterState,
studio: {
testId: this.studioStore.testId,
suiteId: this.studioStore.suiteId,
url: this.studioStore.url,
},
})
resolve({ reporterState })
})
})
})
@@ -773,14 +768,22 @@ export class EventManager {
* This is also applicable when a user changes their spec file and hot reloads their spec, in which case we need to rebind onMessage
* with the newly creates Cypress.primaryOriginCommunicator
*/
window?.top?.removeEventListener('message', crossOriginOnMessageRef, false)
crossOriginOnMessageRef = ({ data, source }) => {
Cypress?.primaryOriginCommunicator.onMessage({ data, source })
try {
window.top.removeEventListener('message', crossOriginOnMessageRef, false)
crossOriginOnMessageRef = ({ data, source }) => {
Cypress?.primaryOriginCommunicator.onMessage({ data, source })
return undefined
return undefined
}
window.top.addEventListener('message', crossOriginOnMessageRef, false)
} catch (error) {
// in cy-in-cy tests, window.top may not be accessible due to cross-origin restrictions
if (error.name !== 'SecurityError') {
// re-throw any error that's not a cross-origin error
throw error
}
}
window.top.addEventListener('message', crossOriginOnMessageRef, false)
}
_runDriver (runState: RunState, testState: CachedTestState) {

View File

@@ -318,13 +318,6 @@ async function runSpecE2E (config, spec: SpecFile) {
specSrc: encodeURIComponent(spec.relative),
})
// FIXME: BILL Determine where to call client with to force browser repaint
/**
* call the clientWidth to force the browser to repaint for viewport changes
* otherwise firefox may fail when changing the viewport in between origins
* this.refs.container.clientWidth
*/
// append to document, so the iframe will execute the spec
addIframe({
$container,
@@ -356,7 +349,7 @@ async function initialize () {
const studioStore = useStudioStore()
studioStore.cancel()
studioStore.reset()
// TODO(lachlan): UNIFY-1318 - use GraphQL to get the viewport dimensions
// once it is more practical to do so

View File

@@ -1,5 +1,8 @@
<template>
<div class="border-y flex border-gray-50 w-full justify-between">
<div
class="border-y flex border-gray-50 w-full justify-between"
data-cy="studio-toolbar"
>
<div class="flex">
<div class="flex pr-5 pl-5 items-center">
<span
@@ -26,7 +29,10 @@
</div>
</div>
<div class="flex">
<div
class="flex"
data-cy="studio-toolbar-controls"
>
<div class="border rounded-md flex border-gray-100 m-1">
<Tooltip
placement="top"
@@ -34,6 +40,7 @@
<button
:class="`border-r ${controlsClassName}`"
:disabled="studioStore.isLoading"
data-cy="close-studio"
@click="handleClose"
>
<i-cy-delete_x16 />
@@ -49,6 +56,7 @@
<button
:class="`border-r ${controlsClassName}`"
:disabled="studioStore.isLoading"
data-cy="restart-studio"
@click="handleRestart"
>
<i-cy-action-restart_x16 />
@@ -64,6 +72,7 @@
<button
:class="controlsClassName"
:disabled="studioStore.isLoading || studioStore.isEmpty"
data-cy="copy-commands"
@click="handleCopyCommands"
@mouseleave="() => commandsCopied = false"
>
@@ -82,6 +91,7 @@
<button
class="rounded-md bg-indigo-500 mx-3 text-white py-2 px-3 hover:bg-indigo-400 disabled:opacity-50 disabled:pointer-events-none"
:disabled="studioStore.isLoading || studioStore.isEmpty || studioStore.isFailed"
data-cy="save"
@click="handleSaveCommands"
>
{{ t('runner.studio.saveTestButton') }}

View File

@@ -108,6 +108,7 @@ interface StudioRecorderState {
testId?: string
suiteId?: string
url?: string
_initialUrl?: string
fileDetails?: FileDetails
absoluteFile?: string
@@ -138,11 +139,13 @@ export const useStudioStore = defineStore('studioRecorder', {
actions: {
setTestId (testId: string) {
this.testId = testId
this._updateUrlParams(['testId', 'suiteId'])
},
setSuiteId (suiteId: string) {
this.suiteId = suiteId
this.testId = undefined
this._updateUrlParams(['testId', 'suiteId'])
},
clearRunnableIds () {
@@ -182,21 +185,19 @@ export const useStudioStore = defineStore('studioRecorder', {
this.isFailed = true
},
initialize (config, state) {
const { studio } = state
initialize (config) {
const studio = this._getUrlParams()
if (studio) {
if (studio.testId) {
this.setTestId(studio.testId)
}
if (studio.testId) {
this.setTestId(studio.testId)
}
if (studio.suiteId) {
this.setSuiteId(studio.suiteId)
}
if (studio.suiteId) {
this.setSuiteId(studio.suiteId)
}
if (studio.url) {
this.setUrl(studio.url)
}
if (studio.url) {
this._initialUrl = studio.url
}
if (this.testId || this.suiteId) {
@@ -240,8 +241,8 @@ export const useStudioStore = defineStore('studioRecorder', {
const autStore = useAutStore()
if (this.url) {
this.visitUrl()
if (this._initialUrl || this.url) {
this.visitUrl(this._initialUrl)
}
if (!this.url && autStore.url) {
@@ -266,11 +267,15 @@ export const useStudioStore = defineStore('studioRecorder', {
this._hasStarted = false
this._currentId = 1
this.isFailed = false
this._maybeResetRunnables()
},
cancel () {
this.reset()
this.clearRunnableIds()
this._removeUrlParams()
this._initialUrl = undefined
},
startSave () {
@@ -283,7 +288,6 @@ export const useStudioStore = defineStore('studioRecorder', {
save (testName?: string) {
this.closeSaveModal()
this.stop()
assertNonNullish(this.absoluteFile, `absoluteFile should exist`)
@@ -303,14 +307,25 @@ export const useStudioStore = defineStore('studioRecorder', {
visitUrl (url?: string) {
this.setUrl(url ?? this.url)
getCypress().cy.visit(this.url)
// if we're visiting a new url, update the visit url param
if (url) {
this._updateUrlParams(['url'])
}
this.logs.push({
id: this._getId(),
selector: undefined,
name: 'visit',
message: this.url,
getCypress().cy.visit(this.url).then(() => {
// after visiting a new url, remove the visit url param since it shouldn't be needed anymore
this._removeUrlParams(['url'])
})
// if we're visiting a new url, add the visit log
if (url) {
this.logs.push({
id: this._getId(),
selector: undefined,
name: 'visit',
message: this.url,
})
}
},
_recordEvent (event) {
@@ -423,6 +438,12 @@ export const useStudioStore = defineStore('studioRecorder', {
this._closeAssertionsMenu()
},
saveSuccess () {
this.stop()
this._removeUrlParams()
this._initialUrl = undefined
},
saveError (err: Error) {
return {
id: this.testId,
@@ -527,6 +548,71 @@ export const useStudioStore = defineStore('studioRecorder', {
return Promise.resolve()
},
_maybeResetRunnables () {
const url = new URL(window.location.href)
const hashParams = new URLSearchParams(url.hash)
// if we don't have studio params, then we can reset the runnables
// otherwise, we need to keep the runnables since we're still in studio
if (!hashParams.has('studio')) {
this.clearRunnableIds()
}
},
_getUrlParams () {
const url = new URL(window.location.href)
const hashParams = new URLSearchParams(url.hash)
const testId = hashParams.get('testId')
const suiteId = hashParams.get('suiteId')
const visitUrl = hashParams.get('url')
return { testId, suiteId, url: visitUrl }
},
_updateUrlParams (filter: string[] = ['testId', 'suiteId', 'url']) {
// if we don't have studio params, we don't need to update them
if (!this.testId && !this.suiteId && !this.url) return
const url = new URL(window.location.href)
const hashParams = new URLSearchParams(url.hash)
// if we have studio params, we need to remove them before adding them back
this._removeUrlParams(filter)
// set the studio params
hashParams.set('studio', '')
filter.forEach((param) => {
if (this[param]) hashParams.set(param, this[param])
})
// update the url
url.hash = decodeURIComponent(hashParams.toString())
window.history.replaceState({}, '', url.toString())
},
_removeUrlParams (filter: string[] = ['testId', 'suiteId', 'url']) {
const url = new URL(window.location.href)
const hashParams = new URLSearchParams(url.hash)
// if we don't have studio params, we don't need to remove them
if (!hashParams.has('studio')) return
// remove the studio params
filter.forEach((param) => {
hashParams.delete(param)
})
// if the filter includes all the items, we can also remove the studio param
if (filter.length === 3) {
hashParams.delete('studio')
}
// update the url
url.hash = decodeURIComponent(hashParams.toString())
window.history.replaceState({}, '', url.toString())
},
_trustEvent (event) {
// only capture events sent by the actual user
// but disable the check if we're in a test

View File

@@ -33,8 +33,8 @@ const makePathsAbsoluteToDoc = $utils.memoize((styles, doc) => {
if (!_.isString(styles)) return styles
return styles.replace(anyUrlInCssRe, (_1, _2, filePath) => {
//// the href getter will always resolve an absolute path taking into
//// account things like the current URL and the <base> tag
// the href getter will always resolve an absolute path taking into
// account things like the current URL and the <base> tag
const a = doc.createElement('a')
a.href = filePath
@@ -59,9 +59,9 @@ const makePathsAbsoluteToStylesheet = $utils.memoize((styles, href) => {
}, makePathsAbsoluteToStylesheetCache)
const getExternalCssContents = (href, stylesheet) => {
//// some browsers may throw a SecurityError if the stylesheet is cross-origin
//// https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#Notes
//// for others, it will just be null
// some browsers may throw a SecurityError if the stylesheet is cross-origin
// https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#Notes
// for others, it will just be null
try {
const rules = stylesheet.rules || stylesheet.cssRules
@@ -96,14 +96,14 @@ export const create = ($$, state) => {
const cssHrefToModifiedMap = new LimitedMap()
let newWindow = false
//// we invalidate the cache when css is modified by javascript
// we invalidate the cache when css is modified by javascript
const onCssModified = (href) => {
cssHrefToModifiedMap.set(href, { modified: true })
}
//// the lifecycle of a stylesheet is the lifecycle of the window
//// so track this to know when to re-evaluate the cache in case
//// of css being modified by javascript
// the lifecycle of a stylesheet is the lifecycle of the window
// so track this to know when to re-evaluate the cache in case
// of css being modified by javascript
const onBeforeWindowLoad = () => {
newWindow = true
}
@@ -112,8 +112,8 @@ export const create = ($$, state) => {
const hrefModified = cssHrefToModifiedMap.get(href) || {}
const existing = cssHrefToIdMap.get(href)
//// if we've loaded a new window and the css was invalidated due to javascript
//// we need to re-evaluate since this time around javascript might not change the css
// if we've loaded a new window and the css was invalidated due to javascript
// we need to re-evaluate since this time around javascript might not change the css
if (existing && !hrefModified.modified && !(newWindow && hrefModified.modifiedLast)) {
return existing
}
@@ -125,16 +125,16 @@ export const create = ($$, state) => {
}
const hashedCssContents = md5(cssContents)
//// if we already have these css contents stored, don't store them again
// if we already have these css contents stored, don't store them again
const existingId = cssHashedContentsToIdMap.get(hashedCssContents)
//// id just needs to be a new object reference
//// we add the href for debuggability
// id just needs to be a new object reference
// we add the href for debuggability
const id = existingId || { hrefId: href }
cssHrefToIdMap.set(href, id)
//// if we already have these css contents stored, don't store them again
// if we already have these css contents stored, don't store them again
if (!existingId) {
cssHashedContentsToIdMap.set(hashedCssContents, id)
cssIdToContentsMap.set(id, cssContents)
@@ -158,19 +158,19 @@ export const create = ($$, state) => {
styles = _.filter(styles, isScreenStylesheet)
return _.map(styles, (stylesheet) => {
//// in cases where we can get the CSS as a string, make the paths
//// absolute so that when they're restored by appending them to the page
//// in <style> tags, background images and fonts still properly load
// in cases where we can get the CSS as a string, make the paths
// absolute so that when they're restored by appending them to the page
// in <style> tags, background images and fonts still properly load
const href = stylesheet.href
//// if there's an href, it's a link tag
//// return the CSS rules as a string, or, if cross-origin,
//// a reference to the stylesheet's href
// if there's an href, it's a link tag
// return the CSS rules as a string, or, if cross-origin,
// a reference to the stylesheet's href
if (href) {
return getStyleId(href, stylesheets[href]) || { href }
}
//// otherwise, it's a style tag, and we can just grab its content
// otherwise, it's a style tag, and we can just grab its content
const cssContents = getInlineCssContents(stylesheet, $$)
return makePathsAbsoluteToDoc(cssContents, doc)
@@ -186,7 +186,7 @@ export const create = ($$, state) => {
bodyStyleIds: getStyleIdsFor(doc, $$, stylesheets, 'body'),
}
//// after getting the all the styles on the page, it's no longer a new window
// after getting the all the styles on the page, it's no longer a new window
newWindow = false
return styleIds

View File

@@ -11,7 +11,15 @@ const fetchScript = (scriptWindow, script) => {
}
const extractSourceMap = ([script, contents]) => {
script.fullyQualifiedUrl = `${window.top!.location.origin}${script.relativeUrl}`.replace(/ /g, '%20')
try {
script.fullyQualifiedUrl = `${window.top!.location.origin}${script.relativeUrl}`.replace(/ /g, '%20')
} catch (error) {
// in cy-in-cy tests, window.top may not be accessible due to cross-origin restrictions
if (error.name !== 'SecurityError') {
// re-throw any error that's not a cross-origin error
throw error
}
}
const sourceMap = $sourceMapUtils.extractSourceMap(contents)

View File

@@ -7,7 +7,7 @@ type MessageLines = [string[], string[]] & {messageEnded?: boolean}
// returns tuple of [message, stack]
export const splitStack = (stack: string) => {
const lines = stack.split('\n')
const lines = stack?.split('\n')
return _.reduce(lines, (memo, line) => {
if (memo.messageEnded || stackLineRegex.test(line)) {

View File

@@ -171,13 +171,7 @@ export const createCommonRoutes = ({
res.sendFile(file, { etag: false })
})
// TODO: The below route is not technically correct for cypress in cypress tests.
// We should be using 'config.namespace' to provide the namespace instead of hard coding __cypress, however,
// In the runner when we create the spec bridge we have no knowledge of the namespace used by the server so
// we create a spec bridge for the namespace of the server specified in the config, but that server hasn't been created.
// To fix this I think we need to find a way to listen in the cypress in cypress server for routes from the server the
// cypress instance thinks should exist, but that's outside the current scope.
router.get('/__cypress/spec-bridge-iframes', async (req, res) => {
router.get(`/${config.namespace}/spec-bridge-iframes`, async (req, res) => {
debug('handling cross-origin iframe for domain: %s', req.hostname)
// Chrome plans to make document.domain immutable in Chrome 109, with the default value

View File

@@ -87,6 +87,16 @@ const _forceProxyMiddleware = function (clientRoute, namespace = '__cypress') {
return function (req, res, next) {
const trimmedUrl = _.trimEnd(req.proxiedUrl, '/')
// if this request is a non-proxied cy-in-cy request,
// we need to update the proxiedUrl and allow it to pass through
if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF && _isNonProxiedRequest(req)) {
const referrerUrl = new URL(req.headers.referer)
req.proxiedUrl = `${referrerUrl.origin}${req.proxiedUrl}`
return next()
}
if (_isNonProxiedRequest(req) && !ALLOWED_PROXY_BYPASS_URLS.includes(trimmedUrl) && (trimmedUrl !== trimmedClientRoute)) {
// this request is non-proxied and non-allowed, redirect to the runner error page
return res.redirect(clientRoute)

View File

@@ -1,4 +1,4 @@
import type { ReporterRunState, StudioRecorderState } from './reporter'
import type { ReporterRunState } from './reporter'
interface MochaRunnerState {
startTime?: number
@@ -12,7 +12,6 @@ interface MochaRunnerState {
}
export type RunState = MochaRunnerState & ReporterRunState & {
studio?: StudioRecorderState
isSpecsListOpen?: boolean
}

View File

@@ -1,9 +1,3 @@
export interface StudioRecorderState {
suiteId?: string
testId?: string
url?: string
}
export interface ReporterRunState {
autoScrollingEnabled?: boolean
scrollTop?: number

View File

@@ -0,0 +1,15 @@
describe('studio functionality', () => {
beforeEach(() => {
cy.intercept('GET', 'http://foobar.com:4455/cypress/e2e/index.html', {
statusCode: 200,
body: '<html><body><h1>hello world</h1></body></html>',
headers: {
'content-type': 'text/html',
},
})
})
it('visits a basic html page', () => {
cy.visit('cypress/e2e/index.html')
})
})

View File

@@ -0,0 +1,9 @@
describe('studio functionality', () => {
beforeEach(() => {
cy.visit('cypress/e2e/index.html')
})
it('visits a basic html page', () => {
cy.get('h1').should('have.text', 'Hello, Studio!')
})
})

View File

@@ -1,3 +1,5 @@
it('visits a basic html page', () => {
cy.visit('cypress/e2e/index.html')
describe('studio functionality', () => {
it('visits a basic html page', () => {
cy.visit('cypress/e2e/index.html')
})
})

View File

@@ -2,7 +2,7 @@
// REPLACE THIS COMMENT FOR HOT RELOAD
describe('simple origin', () => {
it('passes', () => {
cy.origin('http://foobar:4455', () => {
cy.origin('http://foobar.com:4455', () => {
cy.log('log me once')
cy.log('log me twice')
})