Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 10 additions & 1 deletion guides/cy-prompt-development.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `cy.prompt` Development

In production, the code used to facilitate the prompt command will be retrieved from the Cloud.
In production, the code used to facilitate the prompt command will be retrieved from the Cloud. While `cy.prompt` is still in its early stages it is hidden behind an environment variable: `CYPRESS_ENABLE_CY_PROMPT` but can also be run against local cloud Studio code via the environment variable: `CYPRESS_LOCAL_CY_PROMPT_PATH`.

To run against locally developed `cy.prompt`:

Expand All @@ -10,6 +10,15 @@ To run against locally developed `cy.prompt`:
- Set:
- `CYPRESS_INTERNAL_ENV=<environment>` (e.g. `staging` or `production` if you want to hit those deployments of `cypress-services` or `development` if you want to hit a locally running version of `cypress-services`)
- `CYPRESS_LOCAL_CY_PROMPT_PATH` to the path to the `cypress-services/app/packages/cy-prompt/dist/development` directory

To run against a deployed version of `cy.prompt`:

- Set:
- `CYPRESS_INTERNAL_ENV=<environment>` (e.g. `staging` or `production` if you want to hit those deployments of `cypress-services` or `development` if you want to hit a locally running version of `cypress-services`)
- `CYPRESS_ENABLE_CY_PROMPT=true`

Regardless of running against local or deployed `cy.prompt`:

- Clone the `cypress` repo
- Run `yarn`
- Run `yarn cypress:open`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
describe('src/cy/commands/prompt', () => {
it('errors if wait for ready does not return success', (done) => {
const backendStub = cy.stub(Cypress, 'backend').log(false)

backendStub.callThrough()
backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false })

cy.on('fail', (err) => {
expect(err.message).to.include('error waiting for cy prompt bundle to be downloaded and ready')

done()
})

cy.visit('http://www.foobar.com:3500/fixtures/dom.html')

cy['commandFns']['prompt'].__reset()
// @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag
cy.prompt('Hello, world!')
})
})
71 changes: 45 additions & 26 deletions packages/driver/src/cy/commands/prompt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ declare global {
}

let initializedModule: CyPromptDriverDefaultShape | null = null
const initializeModule = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise<CyPromptDriverDefaultShape> => {
const initializeModule = 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')

Expand Down Expand Up @@ -48,45 +48,64 @@ const initializeModule = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['c
return initializedModule
}

const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']) => {
let cloudModule = initializedModule
const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise<ReturnType<CyPromptDriverDefaultShape['createCyPrompt']> | Error> => {
try {
let cloudModule = initializedModule

if (!cloudModule) {
cloudModule = await initializeModule(Cypress, cy)
}
if (!cloudModule) {
cloudModule = await initializeModule(Cypress)
}

return cloudModule.createCyPrompt({
Cypress: Cypress as CypressInternal,
cy,
eventManager: window.getEventManager ? window.getEventManager() : undefined,
})
return await cloudModule.createCyPrompt({
Cypress: Cypress as CypressInternal,
cy,
eventManager: window.getEventManager ? window.getEventManager() : undefined,
})
} catch (error) {
return error
}
}

export default (Commands, Cypress, cy) => {
if (Cypress.config('experimentalPromptCommand')) {
let initializeCloudCyPromptPromise: Promise<ReturnType<CyPromptDriverDefaultShape['createCyPrompt']>> | undefined
let initializeCloudCyPromptPromise: Promise<ReturnType<CyPromptDriverDefaultShape['createCyPrompt']> | Error> | undefined

if (Cypress.browser.family === 'chromium' || Cypress.browser.name === 'electron') {
initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy)
}

Commands.addAll({
async prompt (message: string, options: object = {}) {
if (!initializeCloudCyPromptPromise) {
// TODO: (cy.prompt) We will look into supporting other browsers (and testing them)
// as this is rolled out
throw new Error('`cy.prompt()` is not supported in this browser.')
}
const prompt = async (message: string, options: object = {}) => {
if (!initializeCloudCyPromptPromise) {
// TODO: (cy.prompt) We will look into supporting other browsers (and testing them)
// as this is rolled out
throw new Error('`cy.prompt()` is not supported in this browser.')
}

try {
const cyPrompt = await initializeCloudCyPromptPromise
try {
const bundleResult = await initializeCloudCyPromptPromise

return await cyPrompt(message, options)
} catch (error) {
// TODO: handle this better
throw new Error(`CyPromptDriver not found: ${error}`)
if (bundleResult instanceof Error) {
throw bundleResult
}
},

const cyPrompt = bundleResult

return await cyPrompt(message, options)
} catch (error) {
// TODO: handle this better
throw new Error(`CyPromptDriver not found: ${error}`)
}
}

// For testing purposes, we can reset the prompt command initialization
// by calling the __reset method.
prompt.__reset = () => {
initializedModule = null
initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy)
}

Commands.addAll({
prompt,
})
}
}
32 changes: 27 additions & 5 deletions packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,45 @@ import tar from 'tar'
import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle'
import path from 'path'

const DOWNLOAD_TIMEOUT = 30000

interface EnsureCyPromptBundleOptions {
cyPromptPath: string
cyPromptUrl: string
projectId?: string
downloadTimeoutMs?: number
}

export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions) => {
/**
* Ensures that the cy prompt bundle is downloaded and extracted into the given path
* @param options - The options for the ensure cy prompt bundle operation
* @param options.cyPromptPath - The path to extract the cy prompt bundle to
* @param options.cyPromptUrl - The URL of the cy prompt bundle
* @param options.projectId - The project ID of the cy prompt bundle
* @param options.downloadTimeoutMs - The timeout for the cy prompt bundle download
*/
export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureCyPromptBundleOptions) => {
const bundlePath = path.join(cyPromptPath, 'bundle.tar')

// First remove cyPromptPath to ensure we have a clean slate
await remove(cyPromptPath)
await ensureDir(cyPromptPath)

await getCyPromptBundle({
cyPromptUrl,
projectId,
bundlePath,
let timeoutId: NodeJS.Timeout

await Promise.race([
getCyPromptBundle({
cyPromptUrl,
projectId,
bundlePath,
}),
new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error('Cy prompt bundle download timed out'))
}, downloadTimeoutMs)
}),
]).finally(() => {
clearTimeout(timeoutId)
})

await tar.extract({
Expand Down
2 changes: 1 addition & 1 deletion packages/server/lib/project-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export class ProjectBase extends EE {

this._server = new ServerBase(cfg)
// @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag
if (cfg.experimentalPromptCommand) {
if (process.env.CYPRESS_ENABLE_CY_PROMPT || cfg.experimentalPromptCommand) {
const cyPromptLifecycleManager = new CyPromptLifecycleManager()

cyPromptLifecycleManager.initializeCyPromptManager({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,23 @@ describe('ensureCyPromptBundle', () => {
cwd: cyPromptPath,
})
})

it('should throw an error if the cy prompt bundle download times out', async () => {
getCyPromptBundleStub.callsFake(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(new Error('Cy prompt bundle download timed out'))
}, 3000)
})
})

const ensureCyPromptBundlePromise = ensureCyPromptBundle({
cyPromptPath: '/tmp/cypress/cy-prompt/123',
cyPromptUrl: 'https://cypress.io/cy-prompt',
projectId: '123',
downloadTimeoutMs: 500,
})

await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Cy prompt bundle download timed out')
})
})
32 changes: 27 additions & 5 deletions packages/server/test/unit/project_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -450,15 +450,37 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
})

describe('CyPromptLifecycleManager', function () {
it('initializes cy prompt lifecycle manager', function () {
let initializeCyPromptManagerStub

afterEach(function () {
delete process.env.CYPRESS_ENABLE_CY_PROMPT
initializeCyPromptManagerStub.restore()
})

it('initializes cy prompt lifecycle manager if experimentalPromptCommand is enabled', function () {
this.config.projectId = 'abc123'
this.config.experimentalPromptCommand = true

sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager')
initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager')

return this.project.open()
.then(() => {
expect(initializeCyPromptManagerStub).to.be.calledWith({
projectId: 'abc123',
cloudDataSource: ctx.cloud,
ctx,
})
})
})

it('initializes cy prompt lifecycle manager if process.env.CYPRESS_ENABLE_CY_PROMPT is enabled', function () {
process.env.CYPRESS_ENABLE_CY_PROMPT = 'true'

initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager')

return this.project.open()
.then(() => {
expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).to.be.calledWith({
expect(initializeCyPromptManagerStub).to.be.calledWith({
projectId: 'abc123',
cloudDataSource: ctx.cloud,
ctx,
Expand All @@ -470,11 +492,11 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
this.config.projectId = 'abc123'
this.config.experimentalPromptCommand = false

sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager')
initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager')

return this.project.open()
.then(() => {
expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).not.to.be.called
expect(initializeCyPromptManagerStub).not.to.be.called
})
})
})
Expand Down