Skip to content

DO NOT MERGE #31805

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2f776b8
feat: cy prompt infrastructure
ryanthemanuel May 16, 2025
cd1c7e3
refactor and add tests
ryanthemanuel May 19, 2025
b843dd5
refactor
ryanthemanuel May 19, 2025
4ecb321
rename experimental config
ryanthemanuel May 20, 2025
aca2301
prompt
ryanthemanuel May 20, 2025
bdbc924
fix test
ryanthemanuel May 20, 2025
d7598f4
chore: prototype moving the plugin to cloud delivered code
ryanthemanuel May 21, 2025
150f98f
move CDP local
ryanthemanuel May 22, 2025
ac75435
add prompt:reset, change signature for cyPrompt
tgriesser May 22, 2025
1749ee6
various improvements
ryanthemanuel May 22, 2025
1c6c5a5
fix types
ryanthemanuel May 23, 2025
49c76ca
pass Cypress to initializer
ryanthemanuel May 23, 2025
269923d
move prompt:reset after _addListeners
tgriesser May 23, 2025
2fcea4e
add getUser & config to prompt init
tgriesser May 23, 2025
778adc9
update type for getConfig
tgriesser May 23, 2025
4728196
reload cy prompt bundle on changes
ryanthemanuel May 23, 2025
640f2fc
removing unneeded return value
ryanthemanuel May 23, 2025
bfd3228
fix watching
ryanthemanuel May 23, 2025
788e7a4
create Cypress.promptBackend
ryanthemanuel May 23, 2025
befa4aa
handle sharing types between cypress-services and cypress
ryanthemanuel May 23, 2025
5ce495c
refactor ejection
ryanthemanuel May 24, 2025
451ce05
fix global mode
ryanthemanuel May 24, 2025
2e702f1
eject code
ryanthemanuel May 24, 2025
f0fcd1b
merge feature branch
ryanthemanuel May 30, 2025
06a04d8
Merge branch 'feat/cy-prompt' into ryanm/prototype/move-cy-prompt-to-…
ryanthemanuel May 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1829,7 +1829,7 @@ declare namespace Cypress {
/**
* TODO: add docs
*/
prompt(message: string, options?: Partial<Loggable & Timeoutable>): Chainable<Subject>
prompt(message: string, options?: Partial<Loggable & Timeoutable>): Chainable<null>

/**
* Read a file and yield its contents.
Expand Down
45 changes: 44 additions & 1 deletion packages/app/src/runner/event-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface AddGlobalListenerOptions {
}

const driverToLocalAndReporterEvents = 'run:start run:end'.split(' ')
const driverToSocketEvents = 'backend:request automation:request mocha recorder:frame dev-server:on-spec-update'.split(' ')
const driverToSocketEvents = 'backend:request prompt:backend:request automation:request mocha recorder:frame dev-server:on-spec-update'.split(' ')
const driverToLocalEvents = 'viewport:changed config stop url:changed page:loading visit:failed visit:blank cypress:in:cypress:runner:event'.split(' ')
const socketRerunEvents = 'runner:restart watched:file:changed'.split(' ')
const socketToDriverEvents = 'net:stubbing:event request:event script:error cross:origin:cookies dev-server:on-spec-updated'.split(' ')
Expand Down Expand Up @@ -336,6 +336,35 @@ export class EventManager {
this._studioCopyToClipboard(cb)
})

this.reporterBus.on('prompt:eject', (testId, logId) => {
const log = Cypress.runner.getEjectionLogRegistry(testId, logId)

const codeModalId = `__cy-prompt-code-modal__`
const toRender = Cypress._.template(`
<div id="${codeModalId}" style="position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; font-family: 'Helvetica Neue', sans-serif;">
<div style="background: #1e1e1e; color: white; border-radius: 8px; width: 80%; max-width: 800px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); overflow: hidden;">
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: #2f2f2f; border-bottom: 1px solid #444;">
<h2 style="margin: 0; font-size: 16px; color: #f8f8f2;"><%= title || 'Code Sample' %></h2>
<button onclick="document.getElementById('${codeModalId}').remove()" style="background: transparent; color: #bbb; border: none; font-size: 18px; cursor: pointer;">×</button>
</div>
<div style="position: relative;">
<pre style="margin: 0; padding: 16px; overflow-x: auto;"><code class="language-<%= language %>"><%= code %></code></pre>
<button onclick="navigator.clipboard.writeText(\`<%= Cypress._.escape(code).replace(/\`/g, '\\\`') %>\`); document.getElementById('${codeModalId}').remove()" style="position: absolute; top: 12px; right: 12px; background: #00bfa5; border: none; padding: 6px 12px; border-radius: 4px; color: white; font-size: 12px; cursor: pointer;">Copy</button>
</div>
</div>
</div>
`)

window.document.body.insertAdjacentHTML(
'beforeend',
toRender({
code: log ?? '',
title: 'cy.prompt ejection',
language: 'JavaScript',
}),
)
})

this.localBus.on('studio:copy:to:clipboard', (cb) => {
this._studioCopyToClipboard(cb)
})
Expand Down Expand Up @@ -465,6 +494,8 @@ export class EventManager {
}

this._addListeners()

Cypress.backend('prompt:reset', config.spec).catch(() => {})
}

isBrowserFamily (family: string) {
Expand Down Expand Up @@ -813,6 +844,18 @@ export class EventManager {
Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response)
})

Cypress.primaryOriginCommunicator.on('prompt:backend:request', async ({ args }, { source, responseEvent }) => {
let response

try {
response = await Cypress.promptBackend(...args)
} catch (error) {
response = { error }
}

Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response)
})

/**
* Call an automation request for the requesting spec bridge since we cannot have websockets in the spec bridges.
* Return it's response.
Expand Down
1 change: 1 addition & 0 deletions packages/driver/src/cross-origin/events/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export const handleSocketEvents = (Cypress) => {
}

Cypress.on('backend:request', (...args) => onRequest('backend:request', args))
Cypress.on('prompt:backend:request', (...args) => onRequest('prompt:backend:request', args))
Cypress.on('automation:request', (...args) => onRequest('automation:request', args))
}
57 changes: 40 additions & 17 deletions packages/driver/src/cy/commands/prompt/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { init, loadRemote } from '@module-federation/runtime'
import type{ CyPromptDriverDefaultShape } from './prompt-driver-types'
import type { CyPromptDriverDefaultShape } from './prompt-driver-types'

interface CyPromptDriver { default: CyPromptDriverDefaultShape }

let initializedCyPrompt: CyPromptDriverDefaultShape | null = null
const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise<CyPromptDriverDefaultShape> => {
// Wait for the cy prompt bundle to be downloaded and ready
const { success } = await Cypress.backend('wait:for:cy:prompt:ready')
const { success } = await Cypress.promptBackend('wait:for:cy:prompt:ready')

if (!success) {
throw new Error('CyPromptDriver not found')
Expand Down Expand Up @@ -39,25 +38,49 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise<CyProm
return module.default
}

const getCloud = async (Cypress: Cypress.Cypress): Promise<CyPromptDriverDefaultShape | Error> => {
try {
let cloud = initializedCyPrompt

if (!cloud) {
cloud = await initializeCloudCyPrompt(Cypress)
}

return cloud
} catch (error) {
// TODO: handle this better
// eslint-disable-next-line no-console
console.error('Error in cy.prompt()', error)

return new Error('CyPromptDriver not found')
}
}

const isError = (value: unknown): value is Error => {
return value instanceof Error
}

export default (Commands, Cypress, cy) => {
if (Cypress.config('experimentalPromptCommand')) {
const cloud = getCloud(Cypress)

Commands.addAll({
async prompt (message: string) {
try {
let cloud = initializedCyPrompt

// If the cy prompt driver is not initialized,
// we need to wait for it to be initialized
// before using it
if (!cloud) {
cloud = await initializeCloudCyPrompt(Cypress)
prompt (text: string | string[], options = {}) {
const promptCmd = cy.state('current')

return cy.wrap(cloud, { log: false }).then((cloudOrError) => {
if (isError(cloudOrError)) {
throw cloudOrError
}

return await cloud.cyPrompt(Cypress, message)
} catch (error) {
// TODO: handle this better
throw new Error(`CyPromptDriver not found: ${error}`)
}
return cloudOrError.cyPrompt({
Cypress,
text,
options,
promptCmd,
cy,
})
})
},
})
}
Expand Down
10 changes: 9 additions & 1 deletion packages/driver/src/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,14 @@ class $Cypress {
}

backend (eventName, ...args) {
return this.backendBase('backend:request', eventName, ...args)
}

promptBackend (eventName, ...args) {
return this.backendBase('prompt:backend:request', eventName, ...args)
}

private backendBase (baseEventName, eventName, ...args) {
return new Promise((resolve, reject) => {
const fn = function (reply) {
const e = reply.error
Expand All @@ -787,7 +795,7 @@ class $Cypress {
return resolve(reply.response)
}

return this.emit('backend:request', eventName, ...args, fn)
return this.emit(baseEventName, eventName, ...args, fn)
})
}

Expand Down
16 changes: 16 additions & 0 deletions packages/driver/src/cypress/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,22 @@ export default {
return
},

getEjectionLogRegistry (testId, logId) {
if (_skipCollectingLogs) return

const test = getTestById(testId)

if (!test) return

const logAttrs = _.find(test.commands || [], (log) => log.id === logId)

if (logAttrs) {
return logAttrs.renderProps.ejectionLogRegistry
}

return
},

getSnapshotPropsForLog (testId, logId) {
if (_skipCollectingLogs) return

Expand Down
1 change: 1 addition & 0 deletions packages/driver/types/internal-types-lite.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ declare namespace Cypress {
toSpecBridge: (origin: string, event: string, data?: any, responseEvent?: string) => void
userInvocationStack?: string
}
promptBackend: (eventName: string, ...args: any[]) => Promise<{ success: boolean }>
}

interface Actions {
Expand Down
10 changes: 10 additions & 0 deletions packages/reporter/src/commands/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,16 @@ const Command: React.FC<CommandProps> = observer(({ model, aliasesWithDuplicates
<CommandControls model={model} commandName={commandName} events={events} />
</div>
</FlashOnClick>
{commandName === 'prompt' && (
<div
className='eject-prompt'
onClick={() => {
events.emit('prompt:eject', model.testId, model.id)
}}
>
<span>Eject Code</span>
</div>
)}
</div>
<Progress model={model} />
{model.hasChildren && model.isOpen && (
Expand Down
14 changes: 14 additions & 0 deletions packages/reporter/src/commands/commands.scss
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@
margin-left: 0;
}
}

.eject-prompt {
text-transform: none;
color: $gray-400;
display: flex;
align-items: center;
padding: 4px;
cursor: pointer;

&:hover,
&:focus {
color: $gray-50;
}
}
}

.command-number-column {
Expand Down
4 changes: 4 additions & 0 deletions packages/reporter/src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ const events: Events = {
localBus.on('studio:copy:to:clipboard', (cb) => {
runner.emit('studio:copy:to:clipboard', cb)
})

localBus.on('prompt:eject', (testId, logId) => {
runner.emit('prompt:eject', testId, logId)
})
},

emit (event, ...args) {
Expand Down
15 changes: 14 additions & 1 deletion packages/server/lib/browsers/browser-cri-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as errors from '../errors'
import type { CypressError } from '@packages/errors'
import { CriClient, DEFAULT_NETWORK_ENABLE_OPTIONS } from './cri-client'
import { serviceWorkerClientEventHandler, serviceWorkerClientEventHandlerName } from '@packages/proxy/lib/http/util/service-worker-manager'
import type { ProtocolManagerShape } from '@packages/types'
import type { CyPromptManagerShape, ProtocolManagerShape } from '@packages/types'
import type { ServiceWorkerEventHandler } from '@packages/proxy/lib/http/util/service-worker-manager'

const debug = Debug('cypress:server:browsers:browser-cri-client')
Expand All @@ -26,6 +26,7 @@ type BrowserCriClientOptions = {
browserName: string
onAsynchronousError: (err: CypressError) => void
protocolManager?: ProtocolManagerShape
cyPromptManager?: CyPromptManagerShape
fullyManageTabs?: boolean
onServiceWorkerClientEvent: ServiceWorkerEventHandler
}
Expand All @@ -38,6 +39,7 @@ type BrowserCriClientCreateOptions = {
onReconnect?: (client: CriClient) => void
port: number
protocolManager?: ProtocolManagerShape
cyPromptManager?: CyPromptManagerShape
onServiceWorkerClientEvent: ServiceWorkerEventHandler
}

Expand Down Expand Up @@ -184,10 +186,12 @@ export class BrowserCriClient {
private browserName: string
private onAsynchronousError: (err: CypressError) => void
private protocolManager?: ProtocolManagerShape
private cyPromptManager?: CyPromptManagerShape
private fullyManageTabs?: boolean
onServiceWorkerClientEvent: ServiceWorkerEventHandler
currentlyAttachedTarget: CriClient | undefined
currentlyAttachedProtocolTarget: CriClient | undefined
currentlyAttachedCyPromptTarget: 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
Expand All @@ -207,6 +211,7 @@ export class BrowserCriClient {
this.browserName = options.browserName
this.onAsynchronousError = options.onAsynchronousError
this.protocolManager = options.protocolManager
this.cyPromptManager = options.cyPromptManager
this.fullyManageTabs = options.fullyManageTabs
this.onServiceWorkerClientEvent = options.onServiceWorkerClientEvent
}
Expand All @@ -223,6 +228,7 @@ export class BrowserCriClient {
* @param options.onReconnect callback for when the browser cri client reconnects to the browser
* @param options.port the port to which to connect
* @param options.protocolManager the protocol manager to use with the browser cri client
* @param options.cyPromptManager the cy prompt manager to use with the browser cri client
* @param options.onServiceWorkerClientEvent callback for when a service worker fetch event is received
* @returns a wrapper around the chrome remote interface that is connected to the browser target
*/
Expand All @@ -235,6 +241,7 @@ export class BrowserCriClient {
onReconnect,
port,
protocolManager,
cyPromptManager,
onServiceWorkerClientEvent,
} = options

Expand All @@ -259,6 +266,7 @@ export class BrowserCriClient {
browserName,
onAsynchronousError,
protocolManager,
cyPromptManager,
fullyManageTabs,
onServiceWorkerClientEvent,
})
Expand Down Expand Up @@ -568,6 +576,11 @@ export class BrowserCriClient {
await this.protocolManager?.connectToBrowser(this.currentlyAttachedProtocolTarget)
}

if (!this.currentlyAttachedCyPromptTarget) {
this.currentlyAttachedCyPromptTarget = await this.currentlyAttachedTarget.clone()
await this.cyPromptManager?.connectToBrowser(this.currentlyAttachedCyPromptTarget)
}

return this.currentlyAttachedTarget
}, this.browserName, this.port)
}
Expand Down
16 changes: 15 additions & 1 deletion packages/server/lib/browsers/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { CriClient } from './cri-client'
import type { Automation } from '../automation'
import memory from './memory'

import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, RunModeVideoApi } from '@packages/types'
import type { BrowserLaunchOpts, BrowserNewTabOpts, CyPromptManagerShape, ProtocolManagerShape, RunModeVideoApi } from '@packages/types'
import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket'
import { DEFAULT_CHROME_FLAGS } from '../util/chromium_flags'

Expand Down Expand Up @@ -412,6 +412,20 @@ export = {
await options.protocolManager?.connectToBrowser(browserCriClient.currentlyAttachedProtocolTarget)
},

async connectCyPromptToBrowser (options: { cyPromptManager?: CyPromptManagerShape }) {
const browserCriClient = this._getBrowserCriClient()

if (!browserCriClient?.currentlyAttachedTarget) throw new Error('Missing pageCriClient in connectCyPromptToBrowser')

// Clone the target here so that we separate the cy prompt client and the main client.
// This allows us to close the cy prompt client independently of the main client
if (!browserCriClient?.currentlyAttachedCyPromptTarget) {
browserCriClient.currentlyAttachedCyPromptTarget = await browserCriClient.currentlyAttachedTarget.clone()
}

await options.cyPromptManager?.connectToBrowser(browserCriClient.currentlyAttachedCyPromptTarget)
},

async closeProtocolConnection () {
const browserCriClient = this._getBrowserCriClient()

Expand Down
Loading
Loading