Skip to content

Commit acaaf30

Browse files
chore: (studio) set up infrastructure for cypress in cypress tests for cloud studio (#31621)
* internal: (studio) set up infrastructure for cypress in cypress tests for cloud studio * remove local code * fix build * fix build * extract cloud env to a constant * Revert "extract cloud env to a constant" This reverts commit 8e9c165.
1 parent 8254b94 commit acaaf30

File tree

10 files changed

+292
-4
lines changed

10 files changed

+292
-4
lines changed

guides/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ For general contributor information, check out [`CONTRIBUTING.md`](../CONTRIBUTI
1919
* [Error handling](./error-handling.md)
2020
* [GraphQL Subscriptions - Overview and Test Guide](./graphql-subscriptions.md)
2121
* [Patching packages](./patch-package.md)
22+
* [Protocol development](./protocol-development.md)
2223
* [Release process](./release-process.md)
24+
* [Studio development](./studio-development.md)
2325
* [Testing other projects](./testing-other-projects.md)
2426
* [Testing strategy and style guide (draft)](./testing-strategy-and-styleguide.md)
2527
* [Writing cross-platform JavaScript](./writing-cross-platform-javascript.md)

guides/studio-development.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,59 @@ or to reference a local `cypress_services` repo:
4242
```sh
4343
CYPRESS_LOCAL_STUDIO_PATH=<path-to-cypress-services/app/studio/dist/development-directory> yarn gulp downloadStudioTypes
4444
```
45+
46+
## Testing
47+
48+
### Unit/Component Testing
49+
50+
The code that supports cloud Studio and lives in the `cypress` monorepo is unit and component tested in a similar fashion to the rest of the code in the repo. See the [contributing guide](https://github.com/cypress-io/cypress/blob/ad353fcc0f7fdc51b8e624a2a1ef4e76ef9400a0/CONTRIBUTING.md?plain=1#L366) for more specifics.
51+
52+
The code that supports cloud Studio and lives in the `cypress-services` monorepo has unit and component tests that live alongside the code in that monorepo.
53+
54+
### Cypress in Cypress Testing
55+
56+
Several helpers are provided to facilitate testing cloud Studio using Cypress in Cypress tests. The [helper file](https://github.com/cypress-io/cypress/blob/ad353fcc0f7fdc51b8e624a2a1ef4e76ef9400a0/packages/app/cypress/e2e/studio/helper.ts) provides a method, `launchStudio` that:
57+
58+
1. Loads a project (by default [`experimental-studio`](https://github.com/cypress-io/cypress/tree/develop/system-tests/projects/experimental-studio)).
59+
2. Navigates to the appropriate spec (by default `specName.cy.js`).
60+
3. Enters Studio either by creating a new test or entering from an existing test via the `createNewTest` parameter
61+
4. Waits for the test to finish executing again in Studio mode.
62+
63+
The above steps actually download the studio code from the cloud and use it for the test. Note that `experimental-studio` is set up to be a `canary` project so it will always get the latest and greatest of the cloud Studio code, whether or not it has been fully promoted to production. Note that this means that if you are writing Cypress in Cypress tests that depend on new functionality delivered from the cloud, the Cypress in Cypress tests cannot be merged until the code lands and is built in the cloud. Local development is still possible however by setting `process.env.CYPRESS_LOCAL_STUDIO_PATH` to your local studio path where we enable studio [here](https://github.com/cypress-io/cypress/blob/develop/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts#L424).
64+
65+
In order to properly engage with Studio AI, we choose to simulate the cloud interactions that enable it via something like:
66+
67+
```js
68+
cy.mockNodeCloudRequest({
69+
url: '/studio/testgen/n69px6/enabled',
70+
method: 'get',
71+
body: { enabled: true },
72+
})
73+
```
74+
75+
To ensure that we get the same results from our Studio AI calls every time, we simulate them via something like:
76+
77+
```js
78+
const aiOutput = 'cy.get(\'button\').should(\'have.text\', \'Increment\')'
79+
cy.mockNodeCloudStreamingRequest({
80+
url: '/studio/testgen/n69px6/generate',
81+
method: 'post',
82+
body: { recommendations: [{ content: aiOutput }] },
83+
})
84+
```
85+
86+
The above two helpers actually mock out the Node requests so we still test the interface between the browser and node with these tests.
87+
88+
Also, since protocol does not work properly on the inner Cypress of Cypress in Cypress tests, we choose to create a dummy protocol which means we need to provide a simulated CDP full snapshot that will be sent to AI via something like:
89+
90+
```js
91+
cy.mockStudioFullSnapshot({
92+
id: 1,
93+
nodeType: 1,
94+
nodeName: 'div',
95+
localName: 'div',
96+
nodeValue: 'div',
97+
children: [],
98+
shadowRoots: [],
99+
})
100+
```

packages/app/cypress/e2e/studio/studio.cy.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,74 @@ describe('studio functionality', () => {
167167

168168
cy.percySnapshot()
169169
})
170+
171+
it('opens a cloud studio session with AI enabled', () => {
172+
cy.mockNodeCloudRequest({
173+
url: '/studio/testgen/n69px6/enabled',
174+
method: 'get',
175+
body: { enabled: true },
176+
})
177+
178+
const aiOutput = 'cy.get(\'button\').should(\'have.text\', \'Increment\')'
179+
180+
cy.mockNodeCloudStreamingRequest({
181+
url: '/studio/testgen/n69px6/generate',
182+
method: 'post',
183+
body: { recommendations: [{ content: aiOutput }] },
184+
})
185+
186+
cy.mockStudioFullSnapshot({
187+
id: 1,
188+
nodeType: 1,
189+
nodeName: 'div',
190+
localName: 'div',
191+
nodeValue: 'div',
192+
children: [],
193+
shadowRoots: [],
194+
})
195+
196+
const deferred = pDefer()
197+
198+
loadProjectAndRunSpec({ enableCloudStudio: true })
199+
200+
cy.findByTestId('studio-panel').should('not.exist')
201+
202+
cy.intercept('/cypress/e2e/index.html', () => {
203+
// wait for the promise to resolve before responding
204+
// this will ensure the studio panel is loaded before the test finishes
205+
return deferred.promise
206+
}).as('indexHtml')
207+
208+
cy.contains('visits a basic html page')
209+
.closest('.runnable-wrapper')
210+
.findByTestId('launch-studio')
211+
.click()
212+
213+
// regular studio is not loaded until after the test finishes
214+
cy.get('[data-cy="hook-name-studio commands"]').should('not.exist')
215+
// cloud studio is loaded immediately
216+
cy.findByTestId('studio-panel').then(() => {
217+
// check for the loading panel from the app first
218+
cy.get('[data-cy="loading-studio-panel"]').should('be.visible')
219+
// we've verified the studio panel is loaded, now resolve the promise so the test can finish
220+
deferred.resolve()
221+
})
222+
223+
cy.wait('@indexHtml')
224+
225+
// Studio re-executes spec before waiting for commands - wait for the spec to finish executing.
226+
cy.waitForSpecToFinish()
227+
228+
// Verify the studio panel is still open
229+
cy.findByTestId('studio-panel')
230+
cy.get('[data-cy="hook-name-studio commands"]')
231+
232+
// Verify that AI is enabled
233+
cy.get('[data-cy="ai-status-text"]').should('contain.text', 'Enabled')
234+
235+
// Verify that the AI output is correct
236+
cy.get('[data-cy="studio-ai-output-textarea"]').should('contain.text', aiOutput)
237+
})
170238
})
171239

172240
it('updates an existing test with an action', () => {

packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,24 @@ import path from 'path'
2424
import execa from 'execa'
2525
import _ from 'lodash'
2626

27-
import type { CyTaskResult, OpenGlobalModeOptions, RemoteGraphQLBatchInterceptor, RemoteGraphQLInterceptor, ResetOptionsResult, WithCtxInjected, WithCtxOptions } from '../support/e2e'
27+
import type { CyTaskResult, OpenGlobalModeOptions, RemoteGraphQLBatchInterceptor, RemoteGraphQLInterceptor, ResetOptionsResult, WithCtxInjected, WithCtxOptions, MockNodeCloudRequestOptions, MockNodeCloudStreamingRequestOptions } from '../support/e2e'
2828
import { fixtureDirs } from '@tooling/system-tests'
2929
import * as inspector from 'inspector'
3030
// tslint:disable-next-line: no-implicit-dependencies - requires cypress
3131
import sinonChai from '@cypress/sinon-chai'
3232
import sinon from 'sinon'
3333
import fs from 'fs-extra'
34+
import nock from 'nock'
3435
import { CYPRESS_REMOTE_MANIFEST_URL, NPM_CYPRESS_REGISTRY_URL } from '@packages/types'
3536

3637
import { CloudQuery } from '@packages/graphql/test/stubCloudTypes'
3738
import pDefer from 'p-defer'
39+
import { Readable } from 'stream'
3840

3941
const pkg = require('@packages/root')
4042

43+
const dummyProtocolPath = path.join(__dirname, '../fixtures/dummy-protocol.js')
44+
4145
export interface InternalOpenProjectCapabilities {
4246
cloudStudio: boolean
4347
}
@@ -196,6 +200,8 @@ async function makeE2ETasks () {
196200
*/
197201
__internal__afterEach () {
198202
delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO
203+
delete process.env.CYPRESS_IN_CYPRESS_MOCK_FULL_SNAPSHOT
204+
nock.cleanAll()
199205

200206
return null
201207
},
@@ -422,6 +428,9 @@ async function makeE2ETasks () {
422428
async __internal_openProject ({ argv, projectName, capabilities }: InternalOpenProjectArgs): Promise<ResetOptionsResult> {
423429
if (capabilities.cloudStudio) {
424430
process.env.CYPRESS_ENABLE_CLOUD_STUDIO = 'true'
431+
// Cypress in Cypress testing breaks pretty heavily in terms of the inner Cypress's protocol. For now, we essentially
432+
// disable the protocol by using a dummy protocol that does nothing and allowing tests to mock studio full snapshots as needed.
433+
process.env.CYPRESS_LOCAL_PROTOCOL_PATH = dummyProtocolPath
425434
}
426435

427436
let projectMatched = false
@@ -463,6 +472,52 @@ async function makeE2ETasks () {
463472
e2eServerPort: ctx.coreData.servers.appServerPort,
464473
}
465474
},
475+
__internal_mockStudioFullSnapshot (fullSnapshot: Record<string, any>) {
476+
// This is the outlet to provide a mock full snapshot for studio tests.
477+
// This is necessary because protocol does not capture things properly in the inner Cypress
478+
// when running in Cypress in Cypress.
479+
process.env.CYPRESS_IN_CYPRESS_MOCK_FULL_SNAPSHOT = JSON.stringify(fullSnapshot)
480+
481+
return null
482+
},
483+
__internal_mockNodeCloudRequest ({ url, method, body }: MockNodeCloudRequestOptions) {
484+
const nocked = nock('https://cloud.cypress.io', {
485+
allowUnmocked: true,
486+
})
487+
488+
nocked[method](url).reply(200, body)
489+
490+
return null
491+
},
492+
__internal_mockNodeCloudStreamingRequest ({ url, method, body }: MockNodeCloudStreamingRequestOptions) {
493+
const nocked = nock('https://cloud.cypress.io', {
494+
allowUnmocked: true,
495+
})
496+
497+
// This format is exactly what is expected by our cloud streaming requests (currently just our
498+
// interactions with studio AI). Note that this does not replicate how the event streaming
499+
// works exactly, but it is good enough for these Cypress in Cypress purposes and we test
500+
// the full functionality with all edge cases alongside the Studio cloud code.
501+
nocked[method](url).reply(200, () => {
502+
const stream = new Readable({
503+
read () {
504+
this.push('event: chunk\n')
505+
this.push(`data: ${JSON.stringify(body)}\n\n`)
506+
this.push('event: end\n')
507+
this.push('data: \n\n')
508+
this.push(null)
509+
},
510+
})
511+
512+
return stream
513+
}, {
514+
'Content-Type': 'text/event-stream',
515+
'Cache-Control': 'no-cache',
516+
Connection: 'keep-alive',
517+
})
518+
519+
return null
520+
},
466521
async __internal_withCtx (obj: WithCtxObj): Promise<CyTaskResult<any>> {
467522
const options: WithCtxInjected = {
468523
...obj.options,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const { Readable } = require('stream')
2+
3+
class AppCaptureProtocol {
4+
uploadStallSamplingInterval () {
5+
return 0
6+
}
7+
cdpReconnect () {
8+
return Promise.resolve()
9+
}
10+
responseEndedWithEmptyBody (options) {
11+
return
12+
}
13+
responseStreamTimedOut (options) {
14+
return
15+
}
16+
getDbMetadata () {
17+
return {
18+
offset: 0,
19+
size: 0,
20+
}
21+
}
22+
responseStreamReceived (options) {
23+
return Readable.from([])
24+
}
25+
beforeSpec ({ workingDirectory, archivePath, dbPath, db }) {
26+
}
27+
addRunnables (runnables) {
28+
}
29+
commandLogAdded (log) {
30+
}
31+
commandLogChanged (log) {
32+
}
33+
viewportChanged (input) {
34+
}
35+
urlChanged (input) {
36+
}
37+
beforeTest (test) {
38+
return Promise.resolve()
39+
}
40+
preAfterTest (test, options) {
41+
return Promise.resolve()
42+
}
43+
afterTest (test) {
44+
return Promise.resolve()
45+
}
46+
afterSpec () {
47+
return Promise.resolve({ durations: {} })
48+
}
49+
connectToBrowser (cdpClient) {
50+
return Promise.resolve()
51+
}
52+
pageLoading (input) {
53+
}
54+
resetTest (testId) {
55+
}
56+
}
57+
58+
module.exports = { AppCaptureProtocol }

packages/frontend-shared/cypress/support/e2e.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type sinon from 'sinon'
1212
import type pDefer from 'p-defer'
1313
import 'cypress-plugin-tab'
1414
import type { Response } from 'cross-fetch'
15+
import type nock from 'nock'
1516

1617
import type { E2ETaskMap, InternalOpenProjectCapabilities } from '../e2e/e2ePluginSetup'
1718
import { installCustomPercyCommand } from './customPercyCommand'
@@ -80,6 +81,18 @@ export interface FindBrowsersOptions {
8081
filter?(browser: Browser): boolean
8182
}
8283

84+
export interface MockNodeCloudRequestOptions {
85+
url: string
86+
method: string
87+
body: nock.Body
88+
}
89+
90+
export interface MockNodeCloudStreamingRequestOptions {
91+
url: string
92+
method: string
93+
body: nock.Body
94+
}
95+
8396
export interface ValidateExternalLinkOptions {
8497
/**
8598
* The user-visible descriptor for the link. If omitted, the href
@@ -184,6 +197,20 @@ declare global {
184197
* Get the AUT <iframe>. Useful for Cypress in Cypress tests.
185198
*/
186199
getAutIframe(): Chainable<JQuery<HTMLIFrameElement>>
200+
/**
201+
* Mocks a studio full snapshot as if it were captured in the inner Cypress's protocol.
202+
* This is necessary because protocol does not capture things properly in the inner Cypress
203+
* when running in Cypress in Cypress.
204+
*/
205+
mockStudioFullSnapshot(fullSnapshot: Record<string, any>): void
206+
/**
207+
* Mocks a node cloud request
208+
*/
209+
mockNodeCloudRequest(options: { url: string, method: string, body: nock.Body }): void
210+
/**
211+
* Mocks a node cloud streaming request
212+
*/
213+
mockNodeCloudStreamingRequest(options: { url: string, method: string, body: nock.Body }): void
187214
}
188215

189216
}
@@ -260,6 +287,24 @@ function openProject (projectName: WithPrefix<ProjectFixtureDir>, argv: string[]
260287
})
261288
}
262289

290+
function mockStudioFullSnapshot (fullSnapshot: Record<string, any>) {
291+
return logInternal({ name: 'mockStudioFullSnapshot' }, () => {
292+
return taskInternal('__internal_mockStudioFullSnapshot', fullSnapshot)
293+
})
294+
}
295+
296+
function mockNodeCloudRequest (options: MockNodeCloudRequestOptions) {
297+
return logInternal({ name: 'mockNodeCloudRequest' }, () => {
298+
return taskInternal('__internal_mockNodeCloudRequest', options)
299+
})
300+
}
301+
302+
function mockNodeCloudStreamingRequest (options: MockNodeCloudStreamingRequestOptions) {
303+
return logInternal({ name: 'mockNodeCloudStreamingRequest' }, () => {
304+
return taskInternal('__internal_mockNodeCloudStreamingRequest', options)
305+
})
306+
}
307+
263308
function startAppServer (mode: 'component' | 'e2e' = 'e2e', options: { skipMockingPrompts: boolean } = { skipMockingPrompts: false }) {
264309
const { name, family } = Cypress.browser
265310

@@ -602,6 +647,9 @@ Cypress.Commands.add('remoteGraphQLInterceptBatched', remoteGraphQLInterceptBatc
602647
Cypress.Commands.add('findBrowsers', findBrowsers)
603648
Cypress.Commands.add('tabUntil', tabUntil)
604649
Cypress.Commands.add('validateExternalLink', { prevSubject: ['optional', 'element'] }, validateExternalLink)
650+
Cypress.Commands.add('mockStudioFullSnapshot', mockStudioFullSnapshot)
651+
Cypress.Commands.add('mockNodeCloudRequest', mockNodeCloudRequest)
652+
Cypress.Commands.add('mockNodeCloudStreamingRequest', mockNodeCloudStreamingRequest)
605653

606654
installCustomPercyCommand({
607655
elementOverrides: {

packages/frontend-shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"lodash": "4.17.21",
9090
"markdown-it": "13.0.1",
9191
"modern-normalize": "1.1.0",
92+
"nock": "13.2.9",
9293
"p-defer": "^3.0.0",
9394
"patch-package": "8.0.0",
9495
"postcss": "^8.4.22",

0 commit comments

Comments
 (0)