internal: (studio) add snapshot iframe and pass CDP client (#33109)

This commit is contained in:
Matt Schile
2025-12-16 14:47:18 -07:00
committed by GitHub
parent 7f9fd26627
commit eb882e2adc
29 changed files with 713 additions and 209 deletions

View File

@@ -140,7 +140,8 @@ const specs = window.__RUN_MODE_SPECS__
<style lang="scss">
iframe.aut-iframe {
iframe.aut-iframe,
iframe.aut-snapshot-iframe {
width: 100%;
height: 100%;
background: white;

View File

@@ -2,145 +2,192 @@ import { AutIframe } from './aut-iframe'
import { createEventManager } from '../../cypress/component/support/ctSupport'
import { getElementDimensions } from './dimensions'
describe('AutIframe._addElementBoxModelLayers', () => {
describe('AutIframe', () => {
let autIframe: AutIframe
let mockGetComputedStyle: typeof getComputedStyle
let getComputedStyleCallCount: number
let mockJQuery: any
beforeEach(() => {
getComputedStyleCallCount = 0
mockGetComputedStyle = window.getComputedStyle
window.getComputedStyle = (element: Element, pseudoElement?: string | null) => {
getComputedStyleCallCount++
return mockGetComputedStyle.call(window, element, pseudoElement)
}
mockJQuery = (selector: any) => {
if (typeof selector === 'string') {
return {
get: (index?: number) => {
if (selector === 'body') {
return index === 0 ? document.body : [document.body]
}
return null
},
}
}
if (selector && (selector.nodeType || selector instanceof HTMLElement || selector instanceof Element)) {
return {
get: (index?: number) => {
return index === 0 ? selector : [selector]
},
}
}
return {
get: () => null,
}
}
const eventManager = createEventManager()
autIframe = new AutIframe('Test Project', eventManager, mockJQuery)
autIframe = new AutIframe('Test Project', eventManager, Cypress.$)
})
afterEach(() => {
window.getComputedStyle = mockGetComputedStyle
})
context('._addElementBoxModelLayers', () => {
let originalGetComputedStyle: typeof getComputedStyle
let getComputedStyleCallCount: number
it('should not call getComputedStyle when dimensions are provided', () => {
const testElement = document.createElement('div')
beforeEach(() => {
getComputedStyleCallCount = 0
testElement.style.width = '100px'
testElement.style.height = '50px'
testElement.style.padding = '10px'
testElement.style.border = '5px solid black'
testElement.style.margin = '15px'
testElement.style.position = 'absolute'
testElement.style.top = '20px'
testElement.style.left = '30px'
testElement.style.display = 'block'
testElement.style.transform = 'translateX(10px)'
testElement.style.zIndex = '100'
document.body.appendChild(testElement)
originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = (element: Element, pseudoElement?: string | null) => {
getComputedStyleCallCount++
// Get dimensions first (this will call getComputedStyle once)
const dimensions = getElementDimensions(testElement)
// Verify dimensions include transform and zIndex
expect(dimensions.transform).to.exist
expect(dimensions.zIndex).to.exist
// Reset the counter since getElementDimensions also calls getComputedStyle
getComputedStyleCallCount = 0
const $el = mockJQuery(testElement)
const $body = mockJQuery('body')
// When dimensions are provided, _addElementBoxModelLayers should NOT call getComputedStyle
const container = (autIframe as any)._addElementBoxModelLayers($el, $body, dimensions)
// Verify getComputedStyle was NOT called in _addElementBoxModelLayers
// (it should use transform and zIndex from the provided dimensions)
expect(getComputedStyleCallCount).to.equal(0, 'getComputedStyle should not be called when dimensions are provided')
expect(container).to.not.be.undefined
expect(container).to.not.be.null
expect(container).to.be.instanceof(HTMLElement)
expect(container.classList.contains('__cypress-highlight')).to.be.true
expect(container.children.length).to.be.greaterThan(0, 'Should create at least one layer')
const layers = Array.from(container.children) as HTMLElement[]
layers.forEach((layer) => {
expect(layer.style.position).to.equal('absolute')
// Verify positions are stored in data attributes (not style properties)
expect(layer.getAttribute('data-top')).to.exist
expect(layer.getAttribute('data-left')).to.exist
expect(parseFloat(layer.getAttribute('data-top')!)).to.be.a('number')
expect(parseFloat(layer.getAttribute('data-left')!)).to.be.a('number')
expect(layer.getAttribute('data-layer')).to.exist
// Verify transform and zIndex were applied from dimensions
// Note: getComputedStyle returns computed transform as a matrix, not the original CSS value
// So we check that transform is set (not 'none') and matches the computed value from dimensions
expect(layer.style.transform).to.equal(dimensions.transform)
expect(layer.style.zIndex).to.equal('100')
return originalGetComputedStyle.call(window, element, pseudoElement)
}
})
document.body.removeChild(testElement)
afterEach(() => {
window.getComputedStyle = originalGetComputedStyle
})
it('should not call getComputedStyle when dimensions are provided', () => {
const testElement = document.createElement('div')
testElement.style.width = '100px'
testElement.style.height = '50px'
testElement.style.padding = '10px'
testElement.style.border = '5px solid black'
testElement.style.margin = '15px'
testElement.style.position = 'absolute'
testElement.style.top = '20px'
testElement.style.left = '30px'
testElement.style.display = 'block'
testElement.style.transform = 'translateX(10px)'
testElement.style.zIndex = '100'
document.body.appendChild(testElement)
// Get dimensions first (this will call getComputedStyle once)
const dimensions = getElementDimensions(testElement)
// Verify dimensions include transform and zIndex
expect(dimensions.transform).to.exist
expect(dimensions.zIndex).to.exist
// Reset the counter since getElementDimensions also calls getComputedStyle
getComputedStyleCallCount = 0
const $el = Cypress.$(testElement)
const $body = Cypress.$('body')
// When dimensions are provided, _addElementBoxModelLayers should NOT call getComputedStyle
const container = (autIframe as any)._addElementBoxModelLayers($el, $body, dimensions)
// Verify getComputedStyle was NOT called in _addElementBoxModelLayers
// (it should use transform and zIndex from the provided dimensions)
expect(getComputedStyleCallCount).to.equal(0, 'getComputedStyle should not be called when dimensions are provided')
expect(container).to.not.be.undefined
expect(container).to.not.be.null
expect(container).to.be.instanceof(HTMLElement)
expect(container.classList.contains('__cypress-highlight')).to.be.true
expect(container.children.length).to.be.greaterThan(0, 'Should create at least one layer')
const layers = Array.from(container.children) as HTMLElement[]
layers.forEach((layer) => {
expect(layer.style.position).to.equal('absolute')
// Verify positions are stored in data attributes (not style properties)
expect(layer.getAttribute('data-top')).to.exist
expect(layer.getAttribute('data-left')).to.exist
expect(parseFloat(layer.getAttribute('data-top')!)).to.be.a('number')
expect(parseFloat(layer.getAttribute('data-left')!)).to.be.a('number')
expect(layer.getAttribute('data-layer')).to.exist
// Verify transform and zIndex were applied from dimensions
// Note: getComputedStyle returns computed transform as a matrix, not the original CSS value
// So we check that transform is set (not 'none') and matches the computed value from dimensions
expect(layer.style.transform).to.equal(dimensions.transform)
expect(layer.style.zIndex).to.equal('100')
})
document.body.removeChild(testElement)
})
it('should call getComputedStyle only once when dimensions are not provided', () => {
const testElement = document.createElement('div')
testElement.style.width = '100px'
testElement.style.height = '50px'
testElement.style.display = 'block'
document.body.appendChild(testElement)
getComputedStyleCallCount = 0
const $el = Cypress.$(testElement)
const $body = Cypress.$('body')
// Call without providing dimensions (will call getElementDimensions internally)
const container = (autIframe as any)._addElementBoxModelLayers($el, $body)
// getElementDimensions will call getComputedStyle once and return transform/zIndex,
// so _addElementBoxModelLayers won't need to call it again
// We expect only 1 call total (from getElementDimensions)
expect(getComputedStyleCallCount).to.equal(1, 'Should call getComputedStyle only once in getElementDimensions')
expect(container).to.not.be.undefined
expect(container).to.not.be.null
expect(container).to.be.instanceof(HTMLElement)
expect(container.children.length).to.be.greaterThan(0)
document.body.removeChild(testElement)
})
})
it('should call getComputedStyle only once when dimensions are not provided', () => {
const testElement = document.createElement('div')
context('.create', () => {
it('should create both aut iframe and snapshot iframe', () => {
const result = autIframe.create()
testElement.style.width = '100px'
testElement.style.height = '50px'
testElement.style.display = 'block'
document.body.appendChild(testElement)
expect(result).to.have.property('autIframe')
expect(result).to.have.property('autSnapshotIframe')
expect(autIframe.$iframe).to.equal(result.autIframe)
expect(autIframe.$snapshotIframe).to.equal(result.autSnapshotIframe)
})
getComputedStyleCallCount = 0
it('should create aut iframe with correct attributes', () => {
const result = autIframe.create()
const autIframeElement = result.autIframe[0] as HTMLIFrameElement
const $el = mockJQuery(testElement)
const $body = mockJQuery('body')
expect(autIframeElement.id).to.equal('Your project: \'Test Project\'')
expect(autIframeElement.title).to.equal('Your project: \'Test Project\'')
expect(autIframeElement.className).to.equal('aut-iframe')
})
// Call without providing dimensions (will call getElementDimensions internally)
const container = (autIframe as any)._addElementBoxModelLayers($el, $body)
it('should create snapshot iframe with correct attributes', () => {
const result = autIframe.create()
const snapshotIframeElement = result.autSnapshotIframe[0] as HTMLIFrameElement
// getElementDimensions will call getComputedStyle once and return transform/zIndex,
// so _addElementBoxModelLayers won't need to call it again
// We expect only 1 call total (from getElementDimensions)
expect(getComputedStyleCallCount).to.equal(1, 'Should call getComputedStyle only once in getElementDimensions')
expect(snapshotIframeElement.id).to.equal('AUT Snapshot: \'Test Project\'')
expect(snapshotIframeElement.title).to.equal('AUT Snapshot: \'Test Project\'')
expect(snapshotIframeElement.className).to.equal('aut-snapshot-iframe')
})
expect(container).to.not.be.undefined
expect(container).to.not.be.null
expect(container).to.be.instanceof(HTMLElement)
expect(container.children.length).to.be.greaterThan(0)
it('verify the snapshot iframe is hidden', () => {
const result = autIframe.create()
document.body.removeChild(testElement)
result.autSnapshotIframe.appendTo(document.body)
result.autIframe.appendTo(document.body)
expect(result.autSnapshotIframe.is(':hidden')).to.be.true
expect(result.autIframe.is(':hidden')).to.be.false
})
})
context('.destroy', () => {
it('should remove both aut iframe and snapshot iframe', () => {
const result = autIframe.create()
let autIframeRemoved = false
let snapshotIframeRemoved = false
// Mock remove methods
result.autIframe.remove = () => {
autIframeRemoved = true
return result.autIframe
}
result.autSnapshotIframe.remove = () => {
snapshotIframeRemoved = true
return result.autSnapshotIframe
}
autIframe.destroy()
expect(autIframeRemoved).to.be.true
expect(snapshotIframeRemoved).to.be.true
})
it('should throw error when destroy is called without create', () => {
expect(() => autIframe.destroy()).to.throw('Cannot call #remove without first calling #create')
})
})
})

View File

@@ -19,6 +19,8 @@ const jQueryRe = /jquery/i
export class AutIframe {
debouncedToggleSelectorPlayground: DebouncedFunc<(isEnabled: any) => void>
$iframe?: JQuery<HTMLIFrameElement>
// the iframe used to display a snapshot of the AUT (currently used to display the studio snapshots)
$snapshotIframe?: JQuery<HTMLIFrameElement>
_highlightedEl?: Element
private _currentHighlightingId: number = 0
@@ -30,7 +32,7 @@ export class AutIframe {
this.debouncedToggleSelectorPlayground = _.debounce(this.toggleSelectorPlayground, 300)
}
create (): JQuery<HTMLIFrameElement> {
create (): { autIframe: JQuery<HTMLIFrameElement>, autSnapshotIframe: JQuery<HTMLIFrameElement> } {
const $iframe = this.$('<iframe>', {
id: `Your project: '${this.projectName}'`,
title: `Your project: '${this.projectName}'`,
@@ -39,15 +41,28 @@ export class AutIframe {
this.$iframe = $iframe
return $iframe
const $snapshotIframe: JQuery<HTMLIFrameElement> = this.$('<iframe>', {
id: `AUT Snapshot: '${this.projectName}'`,
title: `AUT Snapshot: '${this.projectName}'`,
class: 'aut-snapshot-iframe',
})
$snapshotIframe.hide() // Auto-hide the snapshot iframe
this.$snapshotIframe = $snapshotIframe
return {
autIframe: $iframe,
autSnapshotIframe: $snapshotIframe,
}
}
destroy () {
if (!this.$iframe) {
if (!this.$iframe || !this.$snapshotIframe) {
throw Error(`Cannot call #remove without first calling #create`)
}
this.$iframe.remove()
this.$snapshotIframe.remove()
}
_showInitialBlankPage () {

View File

@@ -477,7 +477,15 @@ export class EventManager {
return getRunnerConfigFromWindow()?.browser?.family === family
}
initialize ($autIframe: JQuery<HTMLIFrameElement>, config: Record<string, any>) {
initialize ({
$autIframe,
$autSnapshotIframe,
config,
}: {
$autIframe: JQuery<HTMLIFrameElement>
$autSnapshotIframe?: JQuery<HTMLIFrameElement>
config: Record<string, any>
}) {
performance.mark('initialize-start')
const testFilter = this.specStore.testFilter
@@ -507,6 +515,7 @@ export class EventManager {
return Cypress.initialize({
$autIframe,
$autSnapshotIframe,
// defining this indicates that the test run should wait for Studio to
// be initialized before running the test
waitForStudio: isStudio ? waitForStudio : undefined,

View File

@@ -240,7 +240,10 @@ function runSpecCT (config, spec: SpecFile) {
// create new AUT
const autIframe = getAutIframeModel()
const $autIframe: JQuery<HTMLIFrameElement> = autIframe.create().appendTo($container)
const { autIframe: $autIframe } = autIframe.create()
$autIframe.appendTo($container)
// the iframe controller will forward the specpath via header to the devserver.
// using a query parameter allows us to recognize relative requests and proxy them to the devserver.
@@ -255,7 +258,7 @@ function runSpecCT (config, spec: SpecFile) {
$autIframe.prop('src', specSrc)
// initialize Cypress (driver) with the AUT!
getEventManager().initialize($autIframe, config)
getEventManager().initialize({ $autIframe, config })
}
/**
@@ -302,7 +305,10 @@ async function runSpecE2E (config, spec: SpecFile) {
// create new AUT
const autIframe = getAutIframeModel()
const $autIframe: JQuery<HTMLIFrameElement> = autIframe.create().appendTo($container)
const { autIframe: $autIframe, autSnapshotIframe: $autSnapshotIframe } = autIframe.create()
$autIframe.appendTo($container)
$autSnapshotIframe.appendTo($container)
// Remove the spec bridge iframe
document.querySelectorAll('iframe.spec-bridge-iframe').forEach((el) => {
@@ -327,7 +333,7 @@ async function runSpecE2E (config, spec: SpecFile) {
})
// initialize Cypress (driver) with the AUT!
getEventManager().initialize($autIframe, config)
getEventManager().initialize({ $autIframe, $autSnapshotIframe, config })
}
/**

View File

@@ -14,14 +14,22 @@
// should be the first same-origin one we come across
const specFrame = window.__isSpecFrame ? window : (() => {
const tryFrame = (index) => {
if (index >= window[TOP].frames.length) {
throw new Error('Spec frame not found')
}
try {
// will throw if cross-origin
window[TOP].frames[index].location.href
const location = window[TOP].frames[index].location
return window[TOP].frames[index]
if (location.pathname.startsWith('/__cypress')) {
return window[TOP].frames[index]
}
} catch (err) {
return tryFrame(index + 1)
// skip if the frame is cross-origin
}
return tryFrame(index + 1)
}
return tryFrame(1)

View File

@@ -122,6 +122,7 @@ class $Cypress {
downloads: any
Commands: any
$autIframe: any
$autSnapshotIframe?: JQuery<HTMLIFrameElement> | null
onSpecReady: any
waitForStudio: any
events: any
@@ -215,6 +216,7 @@ class $Cypress {
this.downloads = null
this.Commands = null
this.$autIframe = null
this.$autSnapshotIframe = null
this.onSpecReady = null
this.waitForStudio = null
this.primaryOriginCommunicator = new PrimaryOriginCommunicator()
@@ -342,8 +344,9 @@ class $Cypress {
return this.action('cypress:config', config)
}
initialize ({ $autIframe, onSpecReady, waitForStudio }) {
initialize ({ $autIframe, $autSnapshotIframe, onSpecReady, waitForStudio }) {
this.$autIframe = $autIframe
this.$autSnapshotIframe = $autSnapshotIframe
this.onSpecReady = onSpecReady
this.waitForStudio = waitForStudio
if (this._onInitialize) {

View File

@@ -0,0 +1,84 @@
/**
* @vitest-environment jsdom
*/
import { vi, describe, it, expect, beforeEach } from 'vitest'
import $Cypress from '../../../src/cypress'
describe('$Cypress', () => {
let Cypress: any
beforeEach(() => {
Cypress = new $Cypress()
vi.resetAllMocks()
})
describe('initialize', () => {
it('should store autIframe and snapshotIframe', () => {
const mockAutIframe = { id: 'aut-iframe' } as any
const mockSnapshotIframe = { id: 'snapshot-iframe' } as any
const mockOnSpecReady = vi.fn()
const mockWaitForStudio = vi.fn()
Cypress.initialize({
$autIframe: mockAutIframe,
$autSnapshotIframe: mockSnapshotIframe,
onSpecReady: mockOnSpecReady,
waitForStudio: mockWaitForStudio,
})
expect(Cypress.$autIframe).toBe(mockAutIframe)
expect(Cypress.$autSnapshotIframe).toBe(mockSnapshotIframe)
expect(Cypress.onSpecReady).toBe(mockOnSpecReady)
expect(Cypress.waitForStudio).toBe(mockWaitForStudio)
})
it('should handle snapshotIframe being undefined', () => {
const mockAutIframe = { id: 'aut-iframe' } as any
const mockOnSpecReady = vi.fn()
Cypress.initialize({
$autIframe: mockAutIframe,
$autSnapshotIframe: undefined,
onSpecReady: mockOnSpecReady,
waitForStudio: undefined,
})
expect(Cypress.$autIframe).toBe(mockAutIframe)
expect(Cypress.$autSnapshotIframe).toBeUndefined()
expect(Cypress.onSpecReady).toBe(mockOnSpecReady)
expect(Cypress.waitForStudio).toBeUndefined()
})
it('should call _onInitialize callback if set', () => {
const mockOnInitialize = vi.fn()
Cypress._onInitialize = mockOnInitialize
const mockAutIframe = { id: 'aut-iframe' } as any
Cypress.initialize({
$autIframe: mockAutIframe,
$autSnapshotIframe: undefined,
onSpecReady: vi.fn(),
waitForStudio: undefined,
})
expect(mockOnInitialize).toHaveBeenCalledOnce()
expect(Cypress._onInitialize).toBeUndefined()
})
it('should not call _onInitialize callback if not set', () => {
const mockAutIframe = { id: 'aut-iframe' } as any
Cypress.initialize({
$autIframe: mockAutIframe,
$autSnapshotIframe: undefined,
onSpecReady: vi.fn(),
waitForStudio: undefined,
})
// Should not throw and should complete successfully
expect(Cypress.$autIframe).toBe(mockAutIframe)
})
})
})

View File

@@ -58,7 +58,7 @@ $spec-list-width: 250px;
box-shadow: none;
left: 0;
.aut-iframe {
.aut-iframe, .aut-snapshot-iframe {
box-shadow: none;
}
}

View File

@@ -19,7 +19,8 @@
display: none;
}
.aut-iframe {
.aut-iframe,
.aut-snapshot-iframe {
background-color: #fff;
box-shadow: 0 6px 10px #555;
border: none;
@@ -52,20 +53,24 @@
display: block;
}
.studio-is-open .aut-iframe {
.studio-is-open .aut-iframe,
.studio-is-open .aut-snapshot-iframe {
border: 4px solid #4997e4;
border-radius: 4px;
}
.studio-is-loading .aut-iframe {
.studio-is-loading .aut-iframe,
.studio-is-loading .aut-snapshot-iframe {
border: 4px solid #b4d5f8;
}
.studio-is-failed .aut-iframe {
.studio-is-failed .aut-iframe,
.studio-is-failed .aut-snapshot-iframe {
border: 4px solid $error;
}
.studio-is-ready .aut-iframe {
.studio-is-ready .aut-iframe,
.studio-is-ready .aut-snapshot-iframe {
animation: flash-iframe-border 1.5s linear infinite;
@keyframes flash-iframe-border {

View File

@@ -181,6 +181,7 @@ export class BrowserCriClient {
currentlyAttachedTarget: CriClient | undefined
currentlyAttachedProtocolTarget: CriClient | undefined
currentlyAttachedCyPromptTarget: CriClient | undefined
currentlyAttachedStudioTarget: CriClient | undefined
// whenever we instantiate the instance we're already connected bc
// we receive an underlying CRI connection
// TODO: remove "connected" in favor of closing/closed or disconnected
@@ -458,10 +459,11 @@ export class BrowserCriClient {
//
// otherwise it means the the browser itself was closed
// always close the connection to the page target because it was destroyed
// always close the connection to the page targets because it was destroyed
browserCriClient.currentlyAttachedTarget.close().catch(() => { })
browserCriClient.currentlyAttachedProtocolTarget?.close().catch(() => { })
browserCriClient.currentlyAttachedCyPromptTarget?.close().catch(() => { })
browserCriClient.currentlyAttachedStudioTarget?.close().catch(() => { })
new Bluebird((resolve) => {
// this event could fire either expectedly or unexpectedly
@@ -588,6 +590,7 @@ export class BrowserCriClient {
this.currentlyAttachedTarget.close().catch(() => {}),
this.currentlyAttachedProtocolTarget?.close().catch(() => {}),
this.currentlyAttachedCyPromptTarget?.close().catch(() => {}),
this.currentlyAttachedStudioTarget?.close().catch(() => {}),
])
debug('target client closed', this.currentlyAttachedTarget.targetId)
@@ -605,6 +608,10 @@ export class BrowserCriClient {
this.browserClient.off(subscription.eventName, subscription.cb as any)
})
this.currentlyAttachedStudioTarget?.queue.subscriptions.forEach((subscription) => {
this.browserClient.off(subscription.eventName, subscription.cb as any)
})
if (target) {
this.currentlyAttachedTarget = await CriClient.create({
target: target.targetId,
@@ -626,14 +633,20 @@ export class BrowserCriClient {
this.currentlyAttachedCyPromptTarget = await currentTarget.clone()
}
const createStudioTarget = async () => {
this.currentlyAttachedStudioTarget = await currentTarget.clone()
}
await Promise.all([
createProtocolTarget(),
createCyPromptTarget(),
createStudioTarget(),
])
} else {
this.currentlyAttachedTarget = undefined
this.currentlyAttachedProtocolTarget = undefined
this.currentlyAttachedCyPromptTarget = undefined
this.currentlyAttachedStudioTarget = undefined
}
this.resettingBrowserTargets = false
@@ -696,6 +709,7 @@ export class BrowserCriClient {
this.currentlyAttachedTarget.close(),
this.currentlyAttachedProtocolTarget?.close(),
this.currentlyAttachedCyPromptTarget?.close(),
this.currentlyAttachedStudioTarget?.close(),
])
}

View File

@@ -20,7 +20,7 @@ import type { CriClient } from './cri-client'
import type { Automation } from '../automation'
import memory from './memory'
import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape, RunModeVideoApi } from '@packages/types'
import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape, StudioManagerShape, RunModeVideoApi } from '@packages/types'
import type { CDPSocketServer } from '@packages/socket'
import { DEFAULT_CHROME_FLAGS } from '../util/chromium_flags'
@@ -464,6 +464,18 @@ export = {
await options.cyPromptManager?.connectToBrowser(browserCriClient.currentlyAttachedCyPromptTarget)
},
async connectStudioToBrowser (options: { studioManager?: StudioManagerShape }) {
const browserCriClient = this._getBrowserCriClient()
if (!browserCriClient?.currentlyAttachedTarget) throw new Error('Missing pageCriClient in connectStudioToBrowser')
if (!browserCriClient.currentlyAttachedStudioTarget) {
browserCriClient.currentlyAttachedStudioTarget = await browserCriClient.currentlyAttachedTarget.clone()
}
await options.studioManager?.connectToBrowser(browserCriClient.currentlyAttachedStudioTarget)
},
async closeProtocolConnection () {
const browserCriClient = this._getBrowserCriClient()

View File

@@ -12,7 +12,7 @@ import type { Browser, BrowserInstance, GracefulShutdownOptions } from './types'
// tslint:disable-next-line no-implicit-dependencies - electron dep needs to be defined
import type { BrowserWindow } from 'electron'
import type { Automation } from '../automation'
import type { BrowserLaunchOpts, Preferences, ProtocolManagerShape, CyPromptManagerShape, RunModeVideoApi } from '@packages/types'
import type { BrowserLaunchOpts, Preferences, ProtocolManagerShape, CyPromptManagerShape, StudioManagerShape, RunModeVideoApi } from '@packages/types'
import type { CDPSocketServer } from '@packages/socket'
import memory from './memory'
import { BrowserCriClient } from './browser-cri-client'
@@ -515,6 +515,19 @@ export = {
await options.cyPromptManager?.connectToBrowser(browserCriClient.currentlyAttachedCyPromptTarget)
},
async connectStudioToBrowser (options: { studioManager?: StudioManagerShape }) {
const browserCriClient = this._getBrowserCriClient()
if (!browserCriClient?.currentlyAttachedTarget) throw new Error('Missing pageCriClient in connectStudioToBrowser')
// Clone the target here so that we separate the studio client and the main client.
if (!browserCriClient.currentlyAttachedStudioTarget) {
browserCriClient.currentlyAttachedStudioTarget = await browserCriClient.currentlyAttachedTarget.clone()
}
await options.studioManager?.connectToBrowser(browserCriClient.currentlyAttachedStudioTarget)
},
async closeProtocolConnection () {
const browserCriClient = this._getBrowserCriClient()

View File

@@ -404,6 +404,10 @@ export function connectCyPromptToBrowser (): Promise<void> {
return Promise.resolve()
}
export function connectStudioToBrowser (): Promise<void> {
return Promise.resolve()
}
export function closeProtocolConnection (): Promise<void> {
throw new Error('Protocol is not yet supported in firefox.')
}

View File

@@ -6,7 +6,7 @@ import * as errors from '../errors'
import { exec } from 'child_process'
import util from 'util'
import os from 'os'
import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser, ProtocolManagerShape, CyPromptManagerShape } from '@packages/types'
import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser, ProtocolManagerShape, CyPromptManagerShape, StudioManagerShape } from '@packages/types'
import type { Browser, BrowserInstance, BrowserLauncher } from './types'
import type { Automation } from '../automation'
import type { DataContext } from '@packages/data-context'
@@ -152,6 +152,12 @@ export = {
await browserLauncher.connectCyPromptToBrowser(options)
},
async connectStudioToBrowser (options: { browser: Browser, foundBrowsers?: FoundBrowser[], studioManager?: StudioManagerShape }) {
const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || [])
await browserLauncher.connectStudioToBrowser(options)
},
async closeProtocolConnection (options: { browser: Browser, foundBrowsers?: FoundBrowser[] }) {
const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || [])

View File

@@ -1,4 +1,4 @@
import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape } from '@packages/types'
import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape, StudioManagerShape } from '@packages/types'
import type { EventEmitter } from 'events'
import type { Automation } from '../automation'
import type { CDPSocketServer } from '@packages/socket'
@@ -49,6 +49,10 @@ export type BrowserLauncher = {
* Used to connect the cy prompt to an existing browser.
*/
connectCyPromptToBrowser: (options: { cyPromptManager?: CyPromptManagerShape }) => Promise<void>
/**
* Used to connect studio to an existing browser.
*/
connectStudioToBrowser: (options: { studioManager?: StudioManagerShape }) => Promise<void>
/**
* Closes the protocol connection to the browser.
*/

View File

@@ -44,6 +44,10 @@ export function connectCyPromptToBrowser (): Promise<void> {
return Promise.resolve()
}
export function connectStudioToBrowser (): Promise<void> {
return Promise.resolve()
}
export function closeProtocolConnection (): Promise<void> {
throw new Error('Protocol is not yet supported in WebKit.')
}

View File

@@ -329,13 +329,16 @@ export class StudioLifecycleManager {
const studioManager = this.studioManager
debug('Calling all studio ready listeners')
debug('Calling %d studio ready listeners', this.listeners.length)
this.listeners.forEach((listener) => {
listener(studioManager)
})
debug('Clearing %d studio ready listeners after successful initialization', this.listeners.length)
this.listeners = []
// In local development, keep listeners so they can be called again after Studio reloads
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
debug('Clearing %d studio ready listeners after successful initialization', this.listeners.length)
this.listeners = []
}
}
private setupWatcher ({
@@ -393,7 +396,12 @@ export class StudioLifecycleManager {
if (this.studioManager) {
debug('Studio ready - calling listener immediately')
listener(this.studioManager)
this.listeners.push(listener)
// If the studio bundle is local, we need to register the listener
// so that we can reload the studio when the bundle changes
if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
this.listeners.push(listener)
}
} else {
debug('Studio not ready - registering studio ready listener')
this.listeners.push(listener)

View File

@@ -1,9 +1,8 @@
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioAddSocketListenersOptions, StudioServerOptions } from '@packages/types'
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioAddSocketListenersOptions, StudioServerOptions, StudioCDPClient } from '@packages/types'
import type { Router } from 'express'
import Debug from 'debug'
import { requireScript } from '../require_script'
import path from 'path'
import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error'
import crypto, { BinaryLike } from 'crypto'
import { StudioElectron } from './StudioElectron'
import exception from '../exception'
@@ -76,6 +75,12 @@ export class StudioManager implements StudioManagerShape {
return !!(await this.invokeAsync('canAccessStudioAI', { isEssential: true }, browser))
}
connectToBrowser (target: StudioCDPClient): void {
if (this._studioServer) {
return this.invokeSync('connectToBrowser', { isEssential: true }, target)
}
}
async initializeStudioAI (options: StudioAIInitializeOptions): Promise<void> {
// Only create a studio electron instance when studio AI is enabled
if (!this._studioElectron) {
@@ -119,6 +124,8 @@ export class StudioManager implements StudioManagerShape {
}
try {
debug('invoking sync method %s with args %o', method, args)
// @ts-expect-error - TS not associating the method & args properly, even though we know it's correct
return this._studioServer[method].apply(this._studioServer, args)
} catch (error: unknown) {
@@ -152,6 +159,8 @@ export class StudioManager implements StudioManagerShape {
}
try {
debug('invoking async method %s with args %o', method, args)
// @ts-expect-error - TS not associating the method & args properly, even though we know it's correct
return await this._studioServer[method].apply(this._studioServer, args)
} catch (error: unknown) {

View File

@@ -5,6 +5,8 @@ export const INITIALIZATION_MARK_NAMES = Object.freeze({
CAN_ACCESS_STUDIO_AI_END: 'can-access-studio-ai-end',
CONNECT_PROTOCOL_TO_BROWSER_START: 'connect-protocol-to-browser-start',
CONNECT_PROTOCOL_TO_BROWSER_END: 'connect-protocol-to-browser-end',
CONNECT_STUDIO_TO_BROWSER_START: 'connect-studio-to-browser-start',
CONNECT_STUDIO_TO_BROWSER_END: 'connect-studio-to-browser-end',
INITIALIZE_STUDIO_AI_START: 'initialize-studio-ai-start',
INITIALIZE_STUDIO_AI_END: 'initialize-studio-ai-end',
} as const)
@@ -15,6 +17,7 @@ export const INITIALIZATION_MEASURE_NAMES = Object.freeze({
INITIALIZATION_DURATION: 'initialization-duration',
CAN_ACCESS_STUDIO_AI_DURATION: 'can-access-studio-ai-duration',
CONNECT_PROTOCOL_TO_BROWSER_DURATION: 'connect-protocol-to-browser-duration',
CONNECT_STUDIO_TO_BROWSER_DURATION: 'connect-studio-to-browser-duration',
INITIALIZE_STUDIO_AI_DURATION: 'initialize-studio-ai-duration',
} as const)
@@ -33,6 +36,10 @@ export const INITIALIZATION_MEASURES: Record<InitializationMeasureName, [Initial
INITIALIZATION_MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_START,
INITIALIZATION_MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_END,
],
[INITIALIZATION_MEASURE_NAMES.CONNECT_STUDIO_TO_BROWSER_DURATION]: [
INITIALIZATION_MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_START,
INITIALIZATION_MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_END,
],
[INITIALIZATION_MEASURE_NAMES.INITIALIZE_STUDIO_AI_DURATION]: [
INITIALIZATION_MARK_NAMES.INITIALIZE_STUDIO_AI_START,
INITIALIZATION_MARK_NAMES.INITIALIZE_STUDIO_AI_END,
@@ -50,6 +57,7 @@ export const INITIALIZATION_TELEMETRY_GROUPS: Record<InitializationTelemetryGrou
INITIALIZATION_MEASURE_NAMES.INITIALIZATION_DURATION,
INITIALIZATION_MEASURE_NAMES.CAN_ACCESS_STUDIO_AI_DURATION,
INITIALIZATION_MEASURE_NAMES.CONNECT_PROTOCOL_TO_BROWSER_DURATION,
INITIALIZATION_MEASURE_NAMES.CONNECT_STUDIO_TO_BROWSER_DURATION,
INITIALIZATION_MEASURE_NAMES.INITIALIZE_STUDIO_AI_DURATION,
],
} as const)

View File

@@ -523,6 +523,10 @@ export class ProjectBase extends EE {
await browsers.connectProtocolToBrowser({ browser: this.browser, foundBrowsers: this.options.browsers, protocolManager: studio.protocolManager })
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_END)
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_START)
await browsers.connectStudioToBrowser({ browser: this.browser, foundBrowsers: this.options.browsers, studioManager: studio })
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_END)
if (!studio.protocolManager.dbPath) {
debug('Protocol database path is not set after initializing protocol manager')

View File

@@ -1,6 +1,6 @@
/// <reference types="cypress" />
import type { StudioServerShape, StudioServerDefaultShape, StudioEvent } from '@packages/types'
import type { StudioServerShape, StudioServerDefaultShape, StudioEvent, StudioCDPClient } from '@packages/types'
import type { Router } from 'express'
import type { Socket } from '@packages/socket'
@@ -36,6 +36,10 @@ class StudioServer implements StudioServerShape {
updateSessionId (sessionId: string): void {
// This is a test implementation that does nothing
}
connectToBrowser (cdpClient: StudioCDPClient): Promise<void> {
return Promise.resolve()
}
}
const studioServerDefault: StudioServerDefaultShape = {

View File

@@ -4,7 +4,7 @@ import { expect, proxyquire, sinon } from '../../spec_helper'
import * as protocol from '../../../lib/browsers/protocol'
import { stripAnsi } from '@packages/errors'
import net from 'net'
import { ProtocolManagerShape, CyPromptManagerShape } from '@packages/types'
import { ProtocolManagerShape, CyPromptManagerShape, StudioManagerShape } from '@packages/types'
import type { Protocol } from 'devtools-protocol'
import { serviceWorkerClientEventHandlerName } from '@packages/proxy/lib/http/util/service-worker-manager'
@@ -14,7 +14,6 @@ const THROWS_PORT = 65535
type GetClientParams = {
protocolManager?: ProtocolManagerShape
cyPromptManager?: CyPromptManagerShape
fullyManageTabs?: boolean
}
@@ -374,6 +373,9 @@ describe('lib/browsers/browser-cri-client', function () {
currentlyAttachedCyPromptTarget: {
close: sinon.stub().resolves(),
},
currentlyAttachedStudioTarget: {
close: sinon.stub().resolves(),
},
resettingBrowserTargets: false,
},
event: {
@@ -391,6 +393,7 @@ describe('lib/browsers/browser-cri-client', function () {
expect(options.browserCriClient.currentlyAttachedTarget.close).not.to.be.called
expect(options.browserCriClient.currentlyAttachedProtocolTarget.close).not.to.be.called
expect(options.browserCriClient.currentlyAttachedCyPromptTarget.close).not.to.be.called
expect(options.browserCriClient.currentlyAttachedStudioTarget.close).not.to.be.called
})
it('closes the extra target client', () => {
@@ -423,6 +426,46 @@ describe('lib/browsers/browser-cri-client', function () {
expect(options.browserCriClient.removeExtraTargetClient).to.be.calledWith('target-id')
})
it('closes the studio target', () => {
options.browserCriClient.gracefulShutdown = true
options.event.targetId = 'main-target-id'
options.browserCriClient.currentlyAttachedStudioTarget.close.resolves()
BrowserCriClient._onTargetDestroyed(options as any)
expect(options.browserCriClient.currentlyAttachedStudioTarget.close).to.be.called
})
it('ignores errors closing the studio target', () => {
options.browserCriClient.gracefulShutdown = true
options.event.targetId = 'main-target-id'
options.browserCriClient.currentlyAttachedStudioTarget.close.rejects(new Error('closing failed'))
BrowserCriClient._onTargetDestroyed(options as any)
expect(options.browserCriClient.currentlyAttachedStudioTarget.close).to.be.called
})
it('closes the cyPrompt target', () => {
options.browserCriClient.gracefulShutdown = true
options.event.targetId = 'main-target-id'
options.browserCriClient.currentlyAttachedCyPromptTarget.close.resolves()
BrowserCriClient._onTargetDestroyed(options as any)
expect(options.browserCriClient.currentlyAttachedCyPromptTarget.close).to.be.called
})
it('ignores errors closing the cyPrompt target', () => {
options.browserCriClient.gracefulShutdown = true
options.event.targetId = 'main-target-id'
options.browserCriClient.currentlyAttachedCyPromptTarget.close.rejects(new Error('closing failed'))
BrowserCriClient._onTargetDestroyed(options as any)
expect(options.browserCriClient.currentlyAttachedCyPromptTarget.close).to.be.called
})
})
})
@@ -572,6 +615,17 @@ describe('lib/browsers/browser-cri-client', function () {
},
}
const mockCurrentlyAttachedStudioTarget = {
targetId: '100',
close: sinon.stub().resolves(sinon.stub().resolves()),
queue: {
subscriptions: [{
eventName: 'Network.requestWillBeSent',
cb: sinon.stub(),
}],
},
}
const mockUpdatedCurrentlyAttachedProtocolTarget = {
targetId: '101',
}
@@ -580,9 +634,16 @@ describe('lib/browsers/browser-cri-client', function () {
targetId: '101',
}
const mockUpdatedCurrentlyAttachedStudioTarget = {
targetId: '101',
}
const mockUpdatedCurrentlyAttachedTarget = {
targetId: '101',
clone: sinon.stub().onFirstCall().returns(mockUpdatedCurrentlyAttachedProtocolTarget).onSecondCall().returns(mockUpdatedCurrentlyAttachedCyPromptTarget),
clone: sinon.stub()
.onFirstCall().returns(mockUpdatedCurrentlyAttachedProtocolTarget)
.onSecondCall().returns(mockUpdatedCurrentlyAttachedCyPromptTarget)
.onThirdCall().returns(mockUpdatedCurrentlyAttachedStudioTarget),
}
send.withArgs('Target.createTarget', { url: 'about:blank' }).resolves(mockUpdatedCurrentlyAttachedTarget)
@@ -595,6 +656,7 @@ describe('lib/browsers/browser-cri-client', function () {
browserClient.currentlyAttachedTarget = mockCurrentlyAttachedTarget
browserClient.currentlyAttachedProtocolTarget = mockCurrentlyAttachedProtocolTarget
browserClient.currentlyAttachedCyPromptTarget = mockCurrentlyAttachedCyPromptTarget
browserClient.currentlyAttachedStudioTarget = mockCurrentlyAttachedStudioTarget
browserClient.browserClient.off = sinon.stub()
await browserClient.resetBrowserTargets(true)
@@ -603,9 +665,11 @@ describe('lib/browsers/browser-cri-client', function () {
expect(browserClient.currentlyAttachedTarget).to.eql(mockUpdatedCurrentlyAttachedTarget)
expect(browserClient.currentlyAttachedProtocolTarget).to.eql(mockUpdatedCurrentlyAttachedProtocolTarget)
expect(browserClient.currentlyAttachedCyPromptTarget).to.eql(mockUpdatedCurrentlyAttachedCyPromptTarget)
expect(browserClient.currentlyAttachedStudioTarget).to.eql(mockUpdatedCurrentlyAttachedStudioTarget)
expect(browserClient.browserClient.off).to.be.calledWith('Network.requestWillBeSent', mockCurrentlyAttachedTarget.queue.subscriptions[0].cb)
expect(browserClient.browserClient.off).to.be.calledWith('Network.requestWillBeSent', mockCurrentlyAttachedProtocolTarget.queue.subscriptions[0].cb)
expect(browserClient.browserClient.off).to.be.calledWith('Network.requestWillBeSent', mockCurrentlyAttachedCyPromptTarget.queue.subscriptions[0].cb)
expect(browserClient.browserClient.off).to.be.calledWith('Network.requestWillBeSent', mockCurrentlyAttachedStudioTarget.queue.subscriptions[0].cb)
})
it('closes the currently attached target without keeping a tab open', async function () {
@@ -633,6 +697,14 @@ describe('lib/browsers/browser-cri-client', function () {
},
}
const mockCurrentlyAttachedStudioTarget = {
targetId: '100',
close: sinon.stub().resolves(sinon.stub().resolves()),
queue: {
subscriptions: [],
},
}
send.withArgs('Target.closeTarget', { targetId: '100' }).resolves()
const browserClient = await getClient() as any
@@ -640,15 +712,18 @@ describe('lib/browsers/browser-cri-client', function () {
browserClient.currentlyAttachedTarget = mockCurrentlyAttachedTarget
browserClient.currentlyAttachedProtocolTarget = mockCurrentlyAttachedProtocolTarget
browserClient.currentlyAttachedCyPromptTarget = mockCurrentlyAttachedCyPromptTarget
browserClient.currentlyAttachedStudioTarget = mockCurrentlyAttachedStudioTarget
await browserClient.resetBrowserTargets(false)
expect(mockCurrentlyAttachedTarget.close).to.be.called
expect(mockCurrentlyAttachedProtocolTarget.close).to.be.called
expect(mockCurrentlyAttachedCyPromptTarget.close).to.be.called
expect(mockCurrentlyAttachedStudioTarget.close).to.be.called
expect(browserClient.currentlyAttachedTarget).to.be.undefined
expect(browserClient.currentlyAttachedProtocolTarget).to.be.undefined
expect(browserClient.currentlyAttachedCyPromptTarget).to.be.undefined
expect(browserClient.currentlyAttachedStudioTarget).to.be.undefined
})
it('throws when there is no currently attached target', async function () {
@@ -704,17 +779,23 @@ describe('lib/browsers/browser-cri-client', function () {
close: sinon.stub().resolves(),
}
const mockCurrentlyAttachedStudioTarget = {
close: sinon.stub().resolves(),
}
const browserClient = await getClient() as any
browserClient.currentlyAttachedTarget = mockCurrentlyAttachedTarget
browserClient.currentlyAttachedProtocolTarget = mockCurrentlyAttachedProtocolTarget
browserClient.currentlyAttachedCyPromptTarget = mockCurrentlyAttachedCyPromptTarget
browserClient.currentlyAttachedStudioTarget = mockCurrentlyAttachedStudioTarget
await browserClient.close()
expect(mockCurrentlyAttachedTarget.close).to.be.called
expect(mockCurrentlyAttachedProtocolTarget.close).to.be.called
expect(mockCurrentlyAttachedCyPromptTarget.close).to.be.called
expect(mockCurrentlyAttachedStudioTarget.close).to.be.called
})
it('just the browser client with no currently attached target', async function () {

View File

@@ -169,6 +169,20 @@ describe('lib/browsers/index', () => {
})
})
context('.connectStudioToBrowser', () => {
it('connects browser to studio', async () => {
sinon.stub(chrome, 'connectStudioToBrowser').resolves()
await browsers.connectStudioToBrowser({
browser: {
family: 'chromium',
},
studioManager: {} as any,
})
expect(chrome.connectStudioToBrowser).to.be.called
})
})
context('.closeProtocolConnection', () => {
it('calls close on instance', async () => {
sinon.stub(chrome, 'closeProtocolConnection').resolves()

View File

@@ -754,6 +754,82 @@ describe('lib/browsers/chrome', () => {
})
})
context('#connectStudioToBrowser', () => {
it('connects to the browser cri client', async function () {
const studioManager = {
connectToBrowser: sinon.stub().resolves(),
}
const mockCurrentlyAttachedStudioTarget = {}
const pageCriClient = {
clone: sinon.stub().returns(mockCurrentlyAttachedStudioTarget),
}
const browserCriClient = {
currentlyAttachedTarget: pageCriClient,
currentlyAttachedStudioTarget: mockCurrentlyAttachedStudioTarget,
}
sinon.stub(chrome, '_getBrowserCriClient').returns(browserCriClient)
await chrome.connectStudioToBrowser({ studioManager })
expect(pageCriClient.clone).not.to.be.called
expect(studioManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedStudioTarget)
})
it('connects to the browser cri client when the studio target has not been created', async function () {
const studioManager = {
connectToBrowser: sinon.stub().resolves(),
}
const mockCurrentlyAttachedStudioTarget = {}
const pageCriClient = {
clone: sinon.stub().resolves(mockCurrentlyAttachedStudioTarget),
}
const browserCriClient = {
currentlyAttachedTarget: pageCriClient,
}
sinon.stub(chrome, '_getBrowserCriClient').returns(browserCriClient)
await chrome.connectStudioToBrowser({ studioManager })
expect(pageCriClient.clone).to.be.called
expect(studioManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedStudioTarget)
expect(browserCriClient.currentlyAttachedStudioTarget).to.eq(mockCurrentlyAttachedStudioTarget)
})
it('throws error if there is no browser cri client', function () {
const studioManager = {
connectToBrowser: sinon.stub().resolves(),
}
sinon.stub(chrome, '_getBrowserCriClient').returns(null)
expect(chrome.connectStudioToBrowser({ studioManager })).to.be.rejectedWith('Missing pageCriClient in connectStudioToBrowser')
expect(studioManager.connectToBrowser).not.to.be.called
})
it('throws error if there is no page cri client', function () {
const studioManager = {
connectToBrowser: sinon.stub().resolves(),
}
const browserCriClient = {
currentlyAttachedTarget: null,
}
sinon.stub(chrome, '_getBrowserCriClient').returns(browserCriClient)
expect(chrome.connectStudioToBrowser({ studioManager })).to.be.rejectedWith('Missing pageCriClient in connectStudioToBrowser')
expect(studioManager.connectToBrowser).not.to.be.called
})
})
context('#closeProtocolConnection', () => {
it('closes the protocol connection', async function () {
const mockCurrentlyAttachedProtocolTarget = {

View File

@@ -28,6 +28,10 @@ describe('lib/browsers/electron', () => {
connectToBrowser: sinon.stub().resolves(),
}
this.studioManager = {
connectToBrowser: sinon.stub().resolves(),
}
this.url = 'https://foo.com'
this.state = {}
this.options = {
@@ -325,6 +329,47 @@ describe('lib/browsers/electron', () => {
})
})
context('.connectStudioToBrowser', () => {
it('connects to the browser cri client', async function () {
const mockCurrentlyAttachedStudioTarget = {}
this.browserCriClient.currentlyAttachedStudioTarget = mockCurrentlyAttachedStudioTarget
sinon.stub(electron, '_getBrowserCriClient').returns(this.browserCriClient)
await electron.connectStudioToBrowser({ studioManager: this.studioManager })
expect(this.pageCriClient.clone).not.to.be.called
expect(this.studioManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedStudioTarget)
})
it('connects to the browser cri client when the studio target has not been created', async function () {
const mockCurrentlyAttachedStudioTarget = {}
this.pageCriClient.clone.resolves(mockCurrentlyAttachedStudioTarget)
sinon.stub(electron, '_getBrowserCriClient').returns(this.browserCriClient)
await electron.connectStudioToBrowser({ studioManager: this.studioManager })
expect(this.pageCriClient.clone).to.be.called
expect(this.studioManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedStudioTarget)
expect(this.browserCriClient.currentlyAttachedStudioTarget).to.eq(mockCurrentlyAttachedStudioTarget)
})
it('throws error if there is no browser cri client', function () {
sinon.stub(electron, '_getBrowserCriClient').returns(null)
expect(electron.connectStudioToBrowser({ studioManager: this.studioManager })).to.be.rejectedWith('Missing pageCriClient in connectStudioToBrowser')
expect(this.studioManager.connectToBrowser).not.to.be.called
})
it('throws error if there is no page cri client', async function () {
this.browserCriClient.currentlyAttachedTarget = null
sinon.stub(electron, '_getBrowserCriClient').returns(this.browserCriClient)
expect(electron.connectStudioToBrowser({ studioManager: this.studioManager })).to.be.rejectedWith('Missing pageCriClient in connectStudioToBrowser')
expect(this.studioManager.connectToBrowser).not.to.be.called
})
})
context('#closeProtocolConnection', () => {
it('closes the protocol connection', async function () {
const mockCurrentlyAttachedProtocolTarget = {

View File

@@ -346,4 +346,38 @@ describe('lib/cloud/studio', () => {
expect(studio.destroy).to.be.called
})
})
describe('connectToBrowser', () => {
it('calls connectToBrowser on the studio server', () => {
const mockCDPClient = {
send: sinon.stub(),
on: sinon.stub(),
off: sinon.stub(),
}
sinon.stub(studio, 'connectToBrowser')
studioManager.connectToBrowser(mockCDPClient as any)
expect(studio.connectToBrowser).to.be.calledWith(mockCDPClient)
})
it('does not call connectToBrowser when studio server is not defined', () => {
// Set _studioServer to undefined
(studioManager as any)._studioServer = undefined
// Create a spy on invokeSync to verify it's not called
const invokeSyncSpy = sinon.spy(studioManager, 'invokeSync')
const mockCDPClient = {
send: sinon.stub(),
on: sinon.stub(),
off: sinon.stub(),
}
studioManager.connectToBrowser(mockCDPClient as any)
expect(invokeSyncSpy).to.not.be.called
})
})
})

View File

@@ -870,6 +870,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
sinon.stub(browsers, 'closeProtocolConnection').resolves()
sinon.stub(browsers, 'connectProtocolToBrowser').resolves()
sinon.stub(browsers, 'connectStudioToBrowser').resolves()
sinon.stub(this.project, 'protocolManager').get(() => {
return this.project['_protocolManager']
}).set((protocolManager) => {
@@ -905,6 +906,12 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
protocolManager: studioManager.protocolManager,
})
expect(browsers.connectStudioToBrowser).to.be.calledWith({
browser: this.project.browser,
foundBrowsers: this.project.options.browsers,
studioManager: studioManager,
})
expect(this.project['_protocolManager']).to.eq(studioManager.protocolManager)
expect(markStub).to.be.calledWith(MARK_NAMES.INITIALIZATION_START)
@@ -913,6 +920,8 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
expect(markStub).to.be.calledWith(MARK_NAMES.CAN_ACCESS_STUDIO_AI_END)
expect(markStub).to.be.calledWith(MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_START)
expect(markStub).to.be.calledWith(MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_END)
expect(markStub).to.be.calledWith(MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_START)
expect(markStub).to.be.calledWith(MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_END)
expect(markStub).to.be.calledWith(MARK_NAMES.INITIALIZE_STUDIO_AI_START)
expect(markStub).to.be.calledWith(MARK_NAMES.INITIALIZE_STUDIO_AI_END)
expect(reportTelemetryStub).to.be.calledWith(TELEMETRY_GROUP_NAMES.INITIALIZE_STUDIO, {
@@ -967,6 +976,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
sinon.stub(browsers, 'closeProtocolConnection').resolves()
sinon.stub(browsers, 'connectProtocolToBrowser').resolves()
sinon.stub(browsers, 'connectStudioToBrowser').resolves()
sinon.stub(this.project, 'protocolManager').get(() => {
return this.project['_protocolManager']
}).set((protocolManager) => {
@@ -1032,6 +1042,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
sinon.stub(browsers, 'closeProtocolConnection').resolves()
sinon.stub(browsers, 'connectProtocolToBrowser').resolves()
sinon.stub(browsers, 'connectStudioToBrowser').resolves()
sinon.stub(this.project, 'protocolManager').get(() => {
return this.project['_protocolManager']
}).set((protocolManager) => {
@@ -1087,6 +1098,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
sinon.stub(browsers, 'closeProtocolConnection').resolves()
sinon.stub(browsers, 'connectProtocolToBrowser').resolves()
sinon.stub(browsers, 'connectStudioToBrowser').resolves()
sinon.stub(this.project, 'protocolManager').get(() => {
return this.project['_protocolManager']
}).set((protocolManager) => {
@@ -1166,6 +1178,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
sinon.stub(browsers, 'closeProtocolConnection').resolves()
sinon.stub(browsers, 'connectProtocolToBrowser').resolves()
sinon.stub(browsers, 'connectStudioToBrowser').resolves()
sinon.stub(this.project, 'protocolManager').get(() => {
return this.project['_protocolManager']
}).set((protocolManager) => {
@@ -1187,6 +1200,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
expect(mockSetupProtocol).not.to.be.called
expect(mockBeforeSpec).not.to.be.called
expect(browsers.connectProtocolToBrowser).not.to.be.called
expect(browsers.connectStudioToBrowser).not.to.be.called
expect(this.project['_protocolManager']).to.be.undefined
expect(markStub).to.be.calledWith(MARK_NAMES.INITIALIZATION_START)
@@ -1195,6 +1209,8 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
expect(markStub).to.be.calledWith(MARK_NAMES.CAN_ACCESS_STUDIO_AI_END)
expect(markStub).not.to.be.calledWith(MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_START)
expect(markStub).not.to.be.calledWith(MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_END)
expect(markStub).not.to.be.calledWith(MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_START)
expect(markStub).not.to.be.calledWith(MARK_NAMES.CONNECT_STUDIO_TO_BROWSER_END)
expect(markStub).not.to.be.calledWith(MARK_NAMES.INITIALIZE_STUDIO_AI_START)
expect(markStub).not.to.be.calledWith(MARK_NAMES.INITIALIZE_STUDIO_AI_END)
expect(reportTelemetryStub).to.be.calledWith(TELEMETRY_GROUP_NAMES.INITIALIZE_STUDIO, {
@@ -1203,62 +1219,6 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
})
})
it('does not capture studio started event if the user is accessing cloud studio', async function () {
process.env.CYPRESS_LOCAL_STUDIO_PATH = 'false'
const mockAccessStudioAI = sinon.stub().resolves(true)
const mockCaptureStudioEvent = sinon.stub().resolves()
this.project.spec = {}
this.project._cfg = this.project._cfg || {}
this.project._cfg.projectId = 'test-project-id'
this.project.ctx.coreData.user = { email: 'test@example.com' }
this.project.ctx.coreData.machineId = Promise.resolve('test-machine-id')
const studioManager = new StudioManager()
studioManager.canAccessStudioAI = mockAccessStudioAI
studioManager.captureStudioEvent = mockCaptureStudioEvent
const studioLifecycleManager = new StudioLifecycleManager()
this.project.ctx.coreData.studioLifecycleManager = studioLifecycleManager
studioLifecycleManager.studioManagerPromise = Promise.resolve(studioManager)
studioLifecycleManager.isStudioReady = sinon.stub().returns(true)
// Create a browser object
this.project.browser = {
name: 'chrome',
family: 'chromium',
}
this.project.options = { browsers: [this.project.browser] }
sinon.stub(browsers, 'closeProtocolConnection').resolves()
sinon.stub(browsers, 'connectProtocolToBrowser').resolves()
sinon.stub(this.project, 'protocolManager').get(() => {
return this.project['_protocolManager']
}).set((protocolManager) => {
this.project['_protocolManager'] = protocolManager
})
let studioInitPromise
this.project.server.startWebsockets.callsFake(async (automation, config, callbacks) => {
studioInitPromise = callbacks.onStudioInit()
})
this.project.startWebsockets({}, {})
const { canAccessStudioAI } = await studioInitPromise
expect(canAccessStudioAI).to.be.false
expect(mockCaptureStudioEvent).not.to.be.called
})
it('onStudioDestroy destroys studio when it is initialized', async function () {
// Set up minimal required properties
this.project.ctx = this.project.ctx || {}

View File

@@ -4,6 +4,7 @@
/// <reference types="cypress" />
import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d'
import type { Router } from 'express'
import type { AxiosInstance } from 'axios'
import type { Socket } from 'socket.io'
@@ -112,11 +113,36 @@ export interface StudioAddSocketListenersOptions {
onAfterSave: (options: { error?: Error }) => void
}
export type StudioCDPCommands = ProtocolMapping.Commands
export type StudioCDPCommand<T extends keyof StudioCDPCommands> =
StudioCDPCommands[T]
export type StudioCDPEvents = ProtocolMapping.Events
export type StudioCDPEvent<T extends keyof StudioCDPEvents> = StudioCDPEvents[T]
export interface StudioCDPClient {
send<T extends Extract<keyof StudioCDPCommands, string>>(
command: T,
params?: StudioCDPCommand<T>['paramsType'][0]
): Promise<StudioCDPCommand<T>['returnType']>
on<T extends Extract<keyof StudioCDPEvents, string>>(
eventName: T,
cb: (event: StudioCDPEvent<T>[0]) => void | Promise<unknown>
): void
off<T extends Extract<keyof StudioCDPEvents, string>>(
eventName: T,
cb: (event: StudioCDPEvent<T>[0]) => void | Promise<unknown>
): void
}
export interface StudioServerShape {
initializeRoutes(router: Router): void
canAccessStudioAI(browser: Cypress.Browser): Promise<boolean>
addSocketListeners(options: StudioAddSocketListenersOptions | Socket): void
initializeStudioAI(options: StudioAIInitializeOptions): Promise<void>
connectToBrowser(cdpClient: StudioCDPClient): void
updateSessionId(sessionId: string): void
reportError(
error: unknown,