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
16 changes: 15 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. 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`.
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 prompt code via the environment variable: `CYPRESS_LOCAL_CY_PROMPT_PATH`.

To run against locally developed `cy.prompt`:

Expand Down Expand Up @@ -30,6 +30,20 @@ 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`)

## Types

The prompt bundle provides the types for the `app`, `driver`, and `server` interfaces that are used within the Cypress code. To incorporate the types into the code base, run:

```sh
yarn gulp downloadPromptTypes
```

or to reference a local `cypress_services` repo:

```sh
CYPRESS_LOCAL_CY_PROMPT_PATH=<path-to-cypress-services/app/cy-prompt/dist/development-directory> yarn gulp downloadPromptTypes
```

## Testing

### Unit/Component Testing
Expand Down
124 changes: 124 additions & 0 deletions packages/app/src/prompt/PromptGetCodeModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<template>
<Dialog
:open="isOpen"
class="inset-0 z-10 fixed overflow-y-auto"
variant="bare"
:initial-focus="container"
@close="closeModal()"
>
<!-- TODO: we need to validate the styles here-->
<div class="flex min-h-screen items-center justify-center">
<DialogOverlay class="bg-gray-800 opacity-90 fixed sm:inset-0" />
<div ref="container" />
</div>
</Dialog>
</template>

<script setup lang="ts">
import { Dialog, DialogOverlay } from '@headlessui/vue'
import { init, loadRemote } from '@module-federation/runtime'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import type { CyPromptAppDefaultShape, GetCodeModalContentsShape } from './prompt-app-types'
import { usePromptStore } from '../store/prompt-store'

interface CyPromptApp { default: CyPromptAppDefaultShape }

// Mirrors the ReactDOM.Root type since incorporating those types
// messes up vue typing elsewhere
interface Root {
render: (element: JSX.Element) => void
unmount: () => void
}

const emit = defineEmits<{
(e: 'close'): void
}>()

withDefaults(defineProps<{
isOpen: boolean
}>(), {
isOpen: false,
})

const closeModal = () => {
emit('close')
}

const container = ref<HTMLDivElement | null>(null)
const error = ref<string | null>(null)
const ReactGetCodeModalContents = ref<GetCodeModalContentsShape | null>(null)
const reactRoot = ref<Root | null>(null)
const promptStore = usePromptStore()

const maybeRenderReactComponent = () => {
if (!ReactGetCodeModalContents.value || !!error.value) {
return
}

const panel = window.UnifiedRunner.React.createElement(ReactGetCodeModalContents.value, {
Cypress,
testId: promptStore.currentGetCodeModalInfo?.testId,
logId: promptStore.currentGetCodeModalInfo?.logId,
onClose: () => {
closeModal()
},
})

if (!reactRoot.value) {
reactRoot.value = window.UnifiedRunner.ReactDOM.createRoot(container.value)
}

reactRoot.value?.render(panel)
}

const unmountReactComponent = () => {
if (!ReactGetCodeModalContents.value || !container.value) {
return
}

reactRoot.value?.unmount()
}

onMounted(maybeRenderReactComponent)
onBeforeUnmount(unmountReactComponent)

init({
remotes: [{
alias: 'cy-prompt',
type: 'module',
name: 'cy-prompt',
entryGlobalName: 'cy-prompt',
entry: '/__cypress-cy-prompt/app/cy-prompt.js',
shareScope: 'default',
}],
name: 'app',
shared: {
react: {
scope: 'default',
version: '18.3.1',
lib: () => window.UnifiedRunner.React,
shareConfig: {
singleton: true,
requiredVersion: '^18.3.1',
},
},
},
})

// We are not using any kind of loading state, because when we get
// to this point, prompt should have already executed, which
// means that the bundle has been downloaded
loadRemote<CyPromptApp>('cy-prompt').then((module) => {
if (!module?.default) {
error.value = 'The panel was not loaded successfully'

return
}

ReactGetCodeModalContents.value = module.default.GetCodeModalContents
maybeRenderReactComponent()
}).catch((e) => {
error.value = e.message
})

</script>
28 changes: 28 additions & 0 deletions packages/app/src/prompt/prompt-app-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Note: This file is owned by the cloud delivered
// cy prompt bundle. It is downloaded and copied here.
// It should not be modified directly here.

export interface CypressInternal extends Cypress.Cypress {
backendRequestHandler: (
backendRequestNamespace: string,
eventName: string,
...args: any[]
) => Promise<any>
}

export interface GetCodeModalContentsProps {
Cypress: CypressInternal
testId: string
logId: string
onClose: () => void
}

export type GetCodeModalContentsShape = (
props: GetCodeModalContentsProps
) => JSX.Element

export interface CyPromptAppDefaultShape {
// Purposefully do not use React in this signature to avoid conflicts when this type gets
// transferred to the Cypress app
GetCodeModalContents: GetCodeModalContentsShape
}
9 changes: 8 additions & 1 deletion packages/app/src/runner/SpecRunnerOpenMode.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<template>
<PromptGetCodeModal
v-if="promptStore.getCodeModalIsOpen"
:open="promptStore.getCodeModalIsOpen"
@close="promptStore.closeGetCodeModal"
/>
<StudioInstructionsModal
v-if="studioStore.instructionModalIsOpen"
:open="studioStore.instructionModalIsOpen"
Expand Down Expand Up @@ -146,6 +151,8 @@ import StudioSaveModal from './studio/StudioSaveModal.vue'
import { useStudioStore } from '../store/studio-store'
import StudioPanel from '../studio/StudioPanel.vue'
import { useSubscription } from '../graphql'
import PromptGetCodeModal from '../prompt/PromptGetCodeModal.vue'
import { usePromptStore } from '../store/prompt-store'

const {
preferredMinimumPanelWidth,
Expand Down Expand Up @@ -236,7 +243,7 @@ const {
} = useEventManager()

const studioStore = useStudioStore()

const promptStore = usePromptStore()
const handleStudioPanelClose = () => {
eventManager.emit('studio:cancel', undefined)
}
Expand Down
24 changes: 24 additions & 0 deletions packages/app/src/runner/event-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { addTelemetryListeners } from './events/telemetry'
import { telemetry } from '@packages/telemetry/src/browser'
import { addCaptureProtocolListeners } from './events/capture-protocol'
import { getRunnerConfigFromWindow } from './get-runner-config-from-window'
import { usePromptStore } from '../store/prompt-store'

export type CypressInCypressMochaEvent = Array<Array<string | Record<string, any>>>

Expand Down Expand Up @@ -61,6 +62,7 @@ export class EventManager {
ws: SocketShape
specStore: ReturnType<typeof useSpecStore>
studioStore: ReturnType<typeof useStudioStore>
promptStore: ReturnType<typeof usePromptStore>

constructor (
// import '@packages/driver'
Expand All @@ -75,6 +77,7 @@ export class EventManager {
this.ws = ws
this.specStore = useSpecStore()
this.studioStore = useStudioStore()
this.promptStore = usePromptStore()
}

getCypress () {
Expand Down Expand Up @@ -418,6 +421,8 @@ export class EventManager {
this._clearAllCookies()
this._setUnload()
})

this.addPromptListeners()
}

start (config) {
Expand Down Expand Up @@ -467,6 +472,12 @@ export class EventManager {
Cypress.state('isProtocolEnabled', isDefaultProtocolEnabled)
}

if (Cypress.config('experimentalPromptCommand')) {
await new Promise((resolve) => {
this.ws.emit('prompt:reset', resolve)
})
}

this._addListeners()
}

Expand Down Expand Up @@ -956,6 +967,10 @@ export class EventManager {
this.localBus.off(event, listener)
}

removeAllListeners (event: string) {
this.localBus.removeAllListeners(event)
}

notifyRunningSpec (specFile) {
this.ws.emit('spec:changed', specFile)
}
Expand Down Expand Up @@ -1006,4 +1021,13 @@ export class EventManager {
_testingOnlySetCypress (cypress: any) {
Cypress = cypress
}

private addPromptListeners () {
this.reporterBus.on('prompt:get-code', ({ testId, logId }) => {
this.promptStore.openGetCodeModal({
testId,
logId,
})
})
}
}
32 changes: 32 additions & 0 deletions packages/app/src/store/prompt-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'

// TODO: Share this
interface GetCodeModalInfo {
testId: string
logId: string
}

interface PromptState {
getCodeModalIsOpen: boolean
currentGetCodeModalInfo: GetCodeModalInfo | null
}

export const usePromptStore = defineStore('prompt', {
state: (): PromptState => {
return {
getCodeModalIsOpen: false,
currentGetCodeModalInfo: null,
}
},
actions: {
openGetCodeModal (getCodeModalInfo: GetCodeModalInfo) {
this.getCodeModalIsOpen = true
this.currentGetCodeModalInfo = getCodeModalInfo
},

closeGetCodeModal () {
this.getCodeModalIsOpen = false
this.currentGetCodeModalInfo = null
},
},
})
18 changes: 3 additions & 15 deletions packages/app/src/studio/studio-app-types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export type RecordingState = 'recording' | 'paused' | 'disabled'

export interface StudioPanelProps {
canAccessStudioAI: boolean
onStudioPanelClose?: () => void
studioSessionId?: string
useRunnerStatus?: RunnerStatusShape
useTestContentRetriever?: TestContentRetrieverShape
useStudioAIStream?: StudioAIStreamShape
useCypress?: CypressShape
}

Expand Down Expand Up @@ -53,6 +54,7 @@ export interface StudioAIStreamProps {
runnerStatus: RunnerStatus
testCode?: string
isCreatingNewTest: boolean
Cypress: CypressInternal
}

export interface StudioAIStream {
Expand All @@ -72,17 +74,3 @@ export type TestContentRetrieverShape = (props: TestContentRetrieverProps) => {
testBlock: TestBlock | null
isCreatingNewTest: boolean
}

export interface Command {
selector?: string
name: string
message?: string | string[]
isAssertion?: boolean
}

export interface SaveDetails {
absoluteFile: string
runnableTitle: string
contents: string
testName?: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('src/cy/commands/prompt', () => {
error.name = 'ENOSPC'

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

cy.on('fail', (err) => {
expect(err.message).to.include('Failed to download cy.prompt Cloud code')
Expand All @@ -31,7 +31,7 @@ describe('src/cy/commands/prompt', () => {
error.name = 'ECONNREFUSED'

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

cy.on('fail', (err) => {
expect(err.message).to.include('Timed out waiting for cy.prompt Cloud code:')
Expand Down
Loading