diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 6611f3ec08ef..0c824a560106 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -1829,7 +1829,7 @@ declare namespace Cypress { /** * TODO: add docs */ - prompt(message: string, options?: Partial): Chainable + prompt(message: string, options?: Partial): Chainable /** * Read a file and yield its contents. diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index b5c3326924c6..37795c5052d7 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -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(' ') @@ -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(` +
+
+
+

<%= title || 'Code Sample' %>

+ +
+
+
<%= code %>
+ +
+
+
+ `) + + 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) }) @@ -465,6 +494,8 @@ export class EventManager { } this._addListeners() + + Cypress.backend('prompt:reset', config.spec).catch(() => {}) } isBrowserFamily (family: string) { @@ -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. diff --git a/packages/driver/src/cross-origin/events/socket.ts b/packages/driver/src/cross-origin/events/socket.ts index a159e1ea5554..754d88bc59b4 100644 --- a/packages/driver/src/cross-origin/events/socket.ts +++ b/packages/driver/src/cross-origin/events/socket.ts @@ -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)) } diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 211cfc4f1c23..ad8e9c230d1a 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -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 => { - // 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') @@ -39,25 +38,49 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise => { + 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, + }) + }) }, }) } diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index c8657904fae7..b95e485c366b 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -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 @@ -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) }) } diff --git a/packages/driver/src/cypress/runner.ts b/packages/driver/src/cypress/runner.ts index d4c1b77ff9f9..a112b2b4ef41 100644 --- a/packages/driver/src/cypress/runner.ts +++ b/packages/driver/src/cypress/runner.ts @@ -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 diff --git a/packages/driver/types/internal-types-lite.d.ts b/packages/driver/types/internal-types-lite.d.ts index c41aef97cf6a..e139464ef7fb 100644 --- a/packages/driver/types/internal-types-lite.d.ts +++ b/packages/driver/types/internal-types-lite.d.ts @@ -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 { diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index 83d813126a53..bc94aa0c1ea5 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -547,6 +547,16 @@ const Command: React.FC = observer(({ model, aliasesWithDuplicates + {commandName === 'prompt' && ( +
{ + events.emit('prompt:eject', model.testId, model.id) + }} + > + Eject Code +
+ )} {model.hasChildren && model.isOpen && ( diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 290869c55e0f..f05223063a7b 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -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 { diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 1d25f9aca4bc..81b2b1a1e68a 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -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) { diff --git a/packages/server/lib/browsers/browser-cri-client.ts b/packages/server/lib/browsers/browser-cri-client.ts index fe6aa66851a0..69683399d079 100644 --- a/packages/server/lib/browsers/browser-cri-client.ts +++ b/packages/server/lib/browsers/browser-cri-client.ts @@ -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') @@ -26,6 +26,7 @@ type BrowserCriClientOptions = { browserName: string onAsynchronousError: (err: CypressError) => void protocolManager?: ProtocolManagerShape + cyPromptManager?: CyPromptManagerShape fullyManageTabs?: boolean onServiceWorkerClientEvent: ServiceWorkerEventHandler } @@ -38,6 +39,7 @@ type BrowserCriClientCreateOptions = { onReconnect?: (client: CriClient) => void port: number protocolManager?: ProtocolManagerShape + cyPromptManager?: CyPromptManagerShape onServiceWorkerClientEvent: ServiceWorkerEventHandler } @@ -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 @@ -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 } @@ -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 */ @@ -235,6 +241,7 @@ export class BrowserCriClient { onReconnect, port, protocolManager, + cyPromptManager, onServiceWorkerClientEvent, } = options @@ -259,6 +266,7 @@ export class BrowserCriClient { browserName, onAsynchronousError, protocolManager, + cyPromptManager, fullyManageTabs, onServiceWorkerClientEvent, }) @@ -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) } diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index c2d969d3b73b..cbf3bac0427e 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -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' @@ -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() diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 07762ae2b7c7..fe1ef2c462f0 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -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, RunModeVideoApi } from '@packages/types' +import type { BrowserLaunchOpts, CyPromptManagerShape, Preferences, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' import memory from './memory' import { BrowserCriClient } from './browser-cri-client' @@ -276,7 +276,7 @@ export = { return this._launch(win, url, automation, electronOptions) }, - async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManagerShape, cdpSocketServer?: CDPSocketServer) { + async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManagerShape, cyPromptManager?: CyPromptManagerShape, cdpSocketServer?: CDPSocketServer) { if (options.show) { menu.set({ withInternalDevTools: true }) } @@ -500,6 +500,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() diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 9cdc2c4e4c9e..c75f565a51ab 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -439,6 +439,11 @@ export function connectProtocolToBrowser (): Promise { throw new Error('Protocol is not yet supported in firefox.') } +export function connectCyPromptToBrowser (): Promise { + // TODO: we will maybe want to connect BIDI for cy prompt in firefox + throw new Error('CyPrompt is not yet supported in firefox.') +} + export function closeProtocolConnection (): Promise { throw new Error('Protocol is not yet supported in firefox.') } diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index b492d76b823a..5a5e01fbcf82 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -7,7 +7,7 @@ import check from 'check-more-types' import { exec } from 'child_process' import util from 'util' import os from 'os' -import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser, ProtocolManagerShape } from '@packages/types' +import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, CyPromptManagerShape, FoundBrowser, ProtocolManagerShape } from '@packages/types' import type { Browser, BrowserInstance, BrowserLauncher } from './types' import type { Automation } from '../automation' import type { DataContext } from '@packages/data-context' @@ -147,6 +147,12 @@ export = { await browserLauncher.connectProtocolToBrowser(options) }, + async connectCyPromptToBrowser (options: { browser: Browser, foundBrowsers?: FoundBrowser[], cyPromptManager?: CyPromptManagerShape }) { + const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || []) + + await browserLauncher.connectCyPromptToBrowser(options) + }, + async closeProtocolConnection (options: { browser: Browser, foundBrowsers?: FoundBrowser[] }) { const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || []) diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index d0579439d622..59668bd41915 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -1,4 +1,4 @@ -import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape } from '@packages/types' +import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape } from '@packages/types' import type { EventEmitter } from 'events' import type { Automation } from '../automation' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' @@ -45,6 +45,10 @@ export type BrowserLauncher = { * Used to connect the protocol to an existing browser. */ connectProtocolToBrowser: (options: { protocolManager?: ProtocolManagerShape }) => Promise + /** + * Used to connect the cy prompt to an existing browser. + */ + connectCyPromptToBrowser: (options: { cyPromptManager?: CyPromptManagerShape }) => Promise /** * Closes the protocol connection to the browser. */ diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index 794990cedccc..c4aa3965022b 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -40,6 +40,11 @@ export function connectProtocolToBrowser (): Promise { throw new Error('Protocol is not yet supported in WebKit.') } +export function connectCyPromptToBrowser (): Promise { + // TODO: evaluate if we can support cy prompt in WebKit + throw new Error('CyPrompt is not yet supported in WebKit.') +} + export function closeProtocolConnection (): Promise { throw new Error('Protocol is not yet supported in WebKit.') } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index e7bce3cd41cb..22eda23c223d 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -10,10 +10,14 @@ import path from 'path' import os from 'os' import { readFile } from 'fs-extra' import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' +import type { AuthenticatedUserShape } from '@packages/data-context/src/data' +import chokidar from 'chokidar' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { + private static hashLoadingMap: Map> = new Map() + private static watcher: chokidar.FSWatcher | null = null private cyPromptManagerPromise?: Promise private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] @@ -34,14 +38,19 @@ export class CyPromptLifecycleManager { cloudDataSource: CloudDataSource ctx: DataContext }): void { + debug('initializing cy prompt') // Register this instance in the data context ctx.update((data) => { data.cyPromptLifecycleManager = this }) + const getUser = () => ctx._apis.authApi.getUser() + const getConfig = () => ctx.project.getConfig() const cyPromptManagerPromise = this.createCyPromptManager({ projectId, cloudDataSource, + getUser, + getConfig, }).catch(async (error) => { debug('Error during cy prompt manager setup: %o', error) @@ -72,6 +81,29 @@ export class CyPromptLifecycleManager { }) this.cyPromptManagerPromise = cyPromptManagerPromise + + // If the cy prompt is local, we need to watch for changes to the cy prompt + // and reload the cy prompt manager on file changes + if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + // Close the watcher if it already exists + if (CyPromptLifecycleManager.watcher) { + CyPromptLifecycleManager.watcher.close() + } + + // Watch for changes to the cy prompt + CyPromptLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'index.js'), { + awaitWriteFinish: true, + }).on('change', () => { + this.createCyPromptManager({ + projectId, + cloudDataSource, + getUser, + getConfig, + }).catch((error) => { + debug('Error during reload of cy prompt manager: %o', error) + }) + }) + } } async getCyPrompt () { @@ -87,26 +119,45 @@ export class CyPromptLifecycleManager { private async createCyPromptManager ({ projectId, cloudDataSource, + getUser, + getConfig, }: { projectId: string cloudDataSource: CloudDataSource + getUser: () => Promise + getConfig: () => Promise> }): Promise { - const cyPromptSession = await postCyPromptSession({ - projectId, - }) + let cyPromptPath: string + let cyPromptHash: string - // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension - const cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] - const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) - const bundlePath = path.join(cyPromptPath, 'bundle.tar') - const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + const cyPromptSession = await postCyPromptSession({ + projectId, + }) - await ensureCyPromptBundle({ - cyPromptUrl: cyPromptSession.cyPromptUrl, - projectId, - cyPromptPath, - bundlePath, - }) + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) + + let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(cyPromptHash) + + if (!hashLoadingPromise) { + hashLoadingPromise = ensureCyPromptBundle({ + cyPromptPath, + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + }) + + CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise) + } + + await hashLoadingPromise + } else { + cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH + cyPromptHash = 'local' + } + + const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') const cyPromptManager = new CyPromptManager() @@ -127,6 +178,8 @@ export class CyPromptLifecycleManager { isRetryableError, asyncRetry, }, + getUser, + config: await getConfig(), }) debug('cy prompt is ready') @@ -148,7 +201,11 @@ export class CyPromptLifecycleManager { listener(cyPromptManager) }) - this.listeners = [] + // Don't clear listeners if the cy prompt is local since we + // will be reloading the cy prompt manager on file changes + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners = [] + } } /** @@ -160,6 +217,12 @@ export class CyPromptLifecycleManager { if (this.cyPromptManager) { debug('cy prompt ready - calling listener immediately') listener(this.cyPromptManager) + + // If the cy prompt is local, we need to register the listener as well + // since the cy prompt manager will be reloaded on file changes + if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners.push(listener) + } } else { debug('cy prompt not ready - registering cy prompt ready listener') this.listeners.push(listener) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index ce247d5a156f..b366d40d6bc7 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -1,16 +1,13 @@ -import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi } from '@packages/types' +import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape } from '@packages/types' +import type { CyPromptCDPClient, CyPromptServerOptions } from '@packages/types/src/cy-prompt/cy-prompt-server-types' import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' interface CyPromptServer { default: CyPromptServerDefaultShape } -interface SetupOptions { +interface SetupOptions extends CyPromptServerOptions { script: string - cyPromptPath: string - cyPromptHash?: string - projectSlug?: string - cloudApi: CyPromptCloudApi } const debug = Debug('cypress:server:cy-prompt') @@ -19,7 +16,7 @@ export class CyPromptManager implements CyPromptManagerShape { status: CyPromptStatus = 'NOT_INITIALIZED' private _cyPromptServer: CyPromptServerShape | undefined - async setup ({ script, cyPromptPath, cyPromptHash, projectSlug, cloudApi }: SetupOptions): Promise { + async setup ({ script, cyPromptPath, cyPromptHash, projectSlug, cloudApi, getUser, config }: SetupOptions): Promise { const { createCyPromptServer } = requireScript(script).default this._cyPromptServer = await createCyPromptServer({ @@ -27,6 +24,8 @@ export class CyPromptManager implements CyPromptManagerShape { cyPromptPath, projectSlug, cloudApi, + getUser, + config, }) this.status = 'INITIALIZED' @@ -40,7 +39,13 @@ export class CyPromptManager implements CyPromptManagerShape { async handleBackendRequest (eventName: string, ...args: any[]): Promise { if (this._cyPromptServer) { - return this.invokeAsync('handleBackendRequest', { isEssential: true }, eventName, ...args) + return this.invokeAsync('handleBackendRequest', { isEssential: false }, eventName, ...args) + } + } + + connectToBrowser (cdpClient: CyPromptCDPClient): void { + if (this._cyPromptServer) { + return this.invokeSync('connectToBrowser', { isEssential: true }, cdpClient) } } @@ -54,6 +59,7 @@ export class CyPromptManager implements CyPromptManagerShape { } try { + // @ts-expect-error - TS not associating the method & args properly, even though we know it's correct return this._cyPromptServer[method].apply(this._cyPromptServer, args) } catch (error: unknown) { let actualError: Error diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 14af0eac0e07..b97c26fee300 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -1,37 +1,30 @@ -import { copy, remove, ensureDir } from 'fs-extra' +import { remove, ensureDir } from 'fs-extra' import tar from 'tar' import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' import path from 'path' interface EnsureCyPromptBundleOptions { - cyPromptPath: string cyPromptUrl: string projectId: string - bundlePath: string + cyPromptPath: string } -export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, bundlePath }: EnsureCyPromptBundleOptions) => { +export const ensureCyPromptBundle = async ({ cyPromptUrl, projectId, cyPromptPath }: EnsureCyPromptBundleOptions) => { + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + // First remove cyPromptPath to ensure we have a clean slate await remove(cyPromptPath) await ensureDir(cyPromptPath) - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { - await getCyPromptBundle({ - cyPromptUrl, - projectId, - bundlePath, - }) - - await tar.extract({ - file: bundlePath, - cwd: cyPromptPath, - }) - } else { - const driverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver') - const serverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server') + await getCyPromptBundle({ + cyPromptUrl, + projectId, + bundlePath, + }) - await copy(driverPath, path.join(cyPromptPath, 'driver')) - await copy(serverPath, path.join(cyPromptPath, 'server')) - } + await tar.extract({ + file: bundlePath, + cwd: cyPromptPath, + }) } diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 797eeac8f590..0f60e82c5189 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -235,6 +235,10 @@ export class OpenProject { await browsers.connectProtocolToBrowser(options) } + async connectCyPromptToBrowser (options) { + await browsers.connectCyPromptToBrowser(options) + } + changeUrlToSpec (spec: Cypress.Spec) { if (!this.projectBase) { debug('No projectBase, cannot change url') diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 42d0986d6477..bd9b39f4d6a8 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -17,7 +17,7 @@ import { SocketCt } from './socket-ct' import { SocketE2E } from './socket-e2e' import { ensureProp } from './util/class-helpers' import system from './util/system' -import { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes } from '@packages/types' +import { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes, CyPromptManagerShape } from '@packages/types' import { DataContext, getCtx } from '@packages/data-context' import { createHmac } from 'crypto' import { ServerBase } from './server-base' @@ -515,6 +515,10 @@ export class ProjectBase extends EE { } }, + onCyPromptReady: (cyPromptManager: CyPromptManagerShape) => { + browsers.connectCyPromptToBrowser({ browser: this.browser, foundBrowsers: this.options.browsers, cyPromptManager }) + }, + onCaptureVideoFrames: (data: any) => { // TODO: move this to browser automation middleware this.emit('capture:video:frames', data) diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index e99e18f5d2bd..cc57859d9438 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -153,6 +153,7 @@ export class SocketBase { onTestFileChange () {}, onCaptureVideoFrames () {}, onStudioInit () {}, + onCyPromptReady () {}, onStudioDestroy () {}, }) @@ -451,9 +452,40 @@ export class SocketBase { let cyPrompt: CyPromptManagerShape | undefined getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cp) => { + options.onCyPromptReady(cp) cyPrompt = cp }) + socket.on('prompt:backend:request', (eventName: string, ...args) => { + const cb = args.pop() + + debug('prompt:backend:request %o', { eventName, args }) + + const promptBackendRequest = () => { + if (eventName === 'prompt:reset' && runState) { + return null + } + + switch (eventName) { + case 'wait:for:cy:prompt:ready': + return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then((cyPrompt) => { + return { + success: cyPrompt && cyPrompt.status === 'INITIALIZED', + } + }) + default: + return cyPrompt?.handleBackendRequest(eventName, ...args) + } + } + + return Bluebird.try(promptBackendRequest) + .then((resp) => { + return cb({ response: resp }) + }).catch((err) => { + return cb({ error: errors.cloneErr(err) }) + }) + }) + socket.on('backend:request', (eventName: string, ...args) => { const userAgent = socket.request?.headers['user-agent'] || getCtx().coreData.app.browserUserAgent diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts index f57a98542cb9..f542fcf99c39 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -31,6 +31,10 @@ describe('lib/cloud/cy-prompt', () => { cyPromptHash: 'abcdefg', projectSlug: '1234', cloudApi: {} as any, + async getUser () { + return {} + }, + config: {}, }) cyPrompt = (cyPromptManager as any)._cyPromptServer diff --git a/scripts/gulp/gulpfile.ts b/scripts/gulp/gulpfile.ts index ca8aef403092..ed71b9a4018b 100644 --- a/scripts/gulp/gulpfile.ts +++ b/scripts/gulp/gulpfile.ts @@ -19,7 +19,7 @@ import { webpackReporter, webpackRunner } from './tasks/gulpWebpack' import { e2eTestScaffold, e2eTestScaffoldWatch } from './tasks/gulpE2ETestScaffold' import dedent from 'dedent' import { ensureCloudValidations, syncCloudValidations } from './tasks/gulpSyncValidations' -import { downloadStudioTypes } from './tasks/gulpCloudDeliveredTypes' +import { downloadStudioTypes, downloadPromptTypes } from './tasks/gulpCloudDeliveredTypes' if (process.env.CYPRESS_INTERNAL_VITE_DEV) { process.env.CYPRESS_INTERNAL_VITE_APP_PORT ??= '3333' @@ -61,6 +61,9 @@ gulp.task( webpackReporter, webpackRunner, gulp.series( + gulp.parallel( + downloadPromptTypes, + ), makePathMap, // Before dev, fetch the latest "remote" schema from Cypress Cloud syncRemoteGraphQL, @@ -257,6 +260,7 @@ gulp.task(openCypressApp) gulp.task(openCypressLaunchpad) gulp.task(downloadStudioTypes) +gulp.task(downloadPromptTypes) // If we want to run individually, for debugging/testing gulp.task('cyOpenLaunchpadOnly', cyOpenLaunchpad) diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index f00c1b1a46ae..5e505943f347 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -4,6 +4,10 @@ import path from 'path' import fs from 'fs-extra' import { retrieveAndExtractStudioBundle, studioPath } from '@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager' import { postStudioSession } from '@packages/server/lib/cloud/api/studio/post_studio_session' +import { ensureCyPromptBundle } from '@packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle' +import { postCyPromptSession } from '@packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session' +import os from 'os' +import chokidar from 'chokidar' export const downloadStudioTypes = async (): Promise => { const studioSession = await postStudioSession({ projectId: 'ypt4pf' }) @@ -20,3 +24,51 @@ export const downloadStudioTypes = async (): Promise => { path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), ) } + +export const downloadPromptTypes = async (): Promise => { + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + const cyPromptSession = await postCyPromptSession({ projectId: 'ypt4pf' }) + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + const cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) + + await ensureCyPromptBundle({ cyPromptUrl: cyPromptSession.cyPromptUrl, cyPromptPath, projectId: 'ypt4pf' }) + + await fs.copyFile( + path.join(cyPromptPath, 'driver', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'driver', 'src', 'cy', 'commands', 'prompt', 'prompt-driver-types.ts'), + ) + + await fs.copyFile( + path.join(cyPromptPath, 'server', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'cy-prompt', 'cy-prompt-server-types.ts'), + ) + } else { + const copyDriverTypes = async () => { + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH!, 'driver', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'driver', 'src', 'cy', 'commands', 'prompt', 'prompt-driver-types.ts'), + ) + } + const copyServerTypes = async () => { + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH!, 'server', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'cy-prompt', 'cy-prompt-server-types.ts'), + ) + } + + const driverWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver', 'types.ts'), { + awaitWriteFinish: true, + }) + + driverWatcher.on('ready', copyDriverTypes) + driverWatcher.on('change', copyDriverTypes) + + const serverWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'types.ts'), { + awaitWriteFinish: true, + }) + + serverWatcher.on('ready', copyServerTypes) + serverWatcher.on('change', copyServerTypes) + } +}