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
149 changes: 124 additions & 25 deletions packages/app/src/studio/studio-app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,34 @@
// `studio` bundle. It is downloaded and copied to the app.
// It should not be modified directly in the app.

import type { CloudStatus } from '@cy/store/user-project-status-store'

// Recording state: 'recording' (active), 'paused' (user-controlled, resumable),
// or 'disabled' (system-controlled, cannot be activated until conditions change).
export type RecordingState = 'recording' | 'paused' | 'disabled'

export type SynchronizationMetadata = {
timestamp: number
sequence: number
frameId: string
}

export type CloudStatus =
| 'isLoggedOut'
| 'needsOrgConnect'
| 'needsProjectConnect'
| 'needsRecordedRun'
| 'allTasksCompleted'

export interface LoginUserData {
id: string
fullName: string | null
email: string | null
}

export interface OrganizationData {
id: string
cypressAiDisabled?: boolean | null
}

export interface UserProjectStatusStore {
user: {
isLoggedIn: boolean
Expand All @@ -17,6 +41,12 @@ export interface UserProjectStatusStore {
openLoginConnectModal: (options: { utmMedium: string }) => void
cloudStatus: CloudStatus
projectId: string
userData?: LoginUserData
organization?: OrganizationData
}

export interface AutSnapshotStore {
isSnapshotPinned: boolean
}

export interface RequestProjectAccessMutationResult {
Expand All @@ -40,7 +70,8 @@ const SPEC_DIRTY_DATA_MODULES = Object.freeze({
},
})

export type SpecDirtyDataModule = (typeof SPEC_DIRTY_DATA_MODULES)[keyof typeof SPEC_DIRTY_DATA_MODULES]
export type SpecDirtyDataModule =
(typeof SPEC_DIRTY_DATA_MODULES)[keyof typeof SPEC_DIRTY_DATA_MODULES]

export type SpecDirtyDataModuleKey = keyof typeof SPEC_DIRTY_DATA_MODULES

Expand All @@ -59,15 +90,17 @@ export interface StudioPanelProps {
useRunnerStatus?: RunnerStatusShape
useTestContentRetriever?: TestContentRetrieverShape
useCypress?: CypressShape
useSnapshotIframe?: SnapshotIframeShape
autUrlSelector?: string
studioAiAvailable?: boolean
userProjectStatusStore: UserProjectStatusStore
hasRequestedProjectAccess: boolean
requestProjectAccessMutation: RequestProjectAccessMutation
specDirtyDataStore: SpecDirtyDataStore
userProjectStatusStore?: UserProjectStatusStore
hasRequestedProjectAccess?: boolean
requestProjectAccessMutation?: RequestProjectAccessMutation
specDirtyDataStore?: SpecDirtyDataStore
isSelectorPlaygroundOpen?: boolean
// Callback to close the Selector Playground
onCloseSelectorPlayground?: () => void
autSnapshotStore?: AutSnapshotStore
}

export type StudioPanelShape = (props: StudioPanelProps) => JSX.Element
Expand All @@ -80,28 +113,82 @@ export interface StudioAppDefaultShape {

export type CypressInternal = Cypress.Cypress &
CyEventEmitter & {
state: (key: string) => any
state<V = any>(key: string): V
state<V = any>(key: string, value: any): V
// The main AUT iframe
$autIframe: JQuery<HTMLIFrameElement>
// The container for the AUT panel, which contains the frames and snapshots controls
$autPanelContainer?: JQuery<HTMLElement>
// The container for the AUT panel iframes, which contains the AUT iframe and snapshot iframes
$autIframesContainer?: JQuery<HTMLElement>
// The individual snapshot iframes rendered by Cypress for studio snapshotrendering
$autSnapshotIframes?: JQuery<HTMLIFrameElement>[]
mocha: {
getRootSuite: () => Suite
getRunner: () => {
stats: {
suites: number
tests: number
}
}
}
areSourceMapsAvailable?: boolean
stackUtils?: {
getSourceDetailsForFirstLine: (stack: string, projectRoot: string) => {
getSourceDetailsForFirstLine: (
stack: string,
projectRoot: string
) => {
line: number
column: number
file: string
}
}
// External typings do not expose the `getSelectorPriority` function on the ElementSelector object
ElementSelector: {
defaults(options: Partial<Cypress.ElementSelectorDefaultsOptions>): void
getSelectorPriority?: () => Cypress.SelectorPriority[]
}
}

export type LocalRecommendationId = string

export interface PreSettledRecommendation {
// client-side generated id, same as the counterpart pending recommendation
id: LocalRecommendationId
// server-side generated id
generationId: string
// the assertions generated by the LLM (may be updated if user edits within the recommendation)
generatedAssertions: string
// the original assertions generated by the LLM (preserved for analytics)
originalGeneratedAssertions: string
// synchronization metadata for each snapshot used to generate this recommendation
snapshotSynchronizationMetadata: SynchronizationMetadata[]
// the reason(s) for the recommendation, from the LLM response
reason?: string[]
// the node ID of the target element for this recommendation (for highlighting)
nodeId?: number
}

// a settled recommendation with the assertions generated by the LLM
export interface SettledRecommendation extends PreSettledRecommendation {
// line that the recommendation starts on
startingLineNumber: number
}

export type SettledRecommendationsById = Record<
LocalRecommendationId,
SettledRecommendation
>

export interface TestBlock {
content: string
testBodyPosition: {
contentStart: number
contentEnd: number
indentation: number
indentation: string
indentationType?: IndentationType
}
recommendations?: SettledRecommendationsById
}

export type RunnerStatus = 'running' | 'finished'
Expand All @@ -122,24 +209,12 @@ export type RunnerStatusShape = (props: RunnerStatusProps) => {
runnerStatus: RunnerStatus
}

export interface StudioAIStreamProps {
canAccessStudioAI: boolean
runnerStatus: RunnerStatus
testCode?: string
isCreatingNewTest: boolean
Cypress: CypressInternal
}

export interface StudioAIStream {
recommendation: string
isStreaming: boolean
generationId: string | null
}

export type StudioAIStreamShape = (props: StudioAIStreamProps) => StudioAIStream
export type SnapshotIframeShape = (props: CypressProps) => void

export interface TestContentRetrieverProps {
Cypress: CypressInternal
showCypressGrepError: boolean
isCucumberSpec: boolean
}

export type TestContentRetrieverShape = (props: TestContentRetrieverProps) => {
Expand All @@ -157,3 +232,27 @@ export type Suite = {
column: number
}
}

export type IndentationType = 'space' | 'tab'

// copied from the Cypress App
export interface AutSnapshot {
id?: number
name?: string
$el: any
snapshot?: AutSnapshot
coords: [number, number]
scrollBy: {
x: number
y: number
}
snapshots: AutSnapshot[]
highlightAttr: string
htmlAttrs: Record<string, any> // Type is NamedNodeMap, not sure if we should include lib: ["DOM"]
viewportHeight: number
viewportWidth: number
url: string
body: {
get: () => unknown // TODO: find out what this is, some sort of JQuery API.
}
}
6 changes: 1 addition & 5 deletions packages/data-context/src/data/ProjectConfigIpc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-dupe-class-members */
import { CypressError, getError } from '@packages/errors'
import type { FullConfig, TestingType } from '@packages/types'
import type { DebugData, FullConfig, TestingType } from '@packages/types'
import { ChildProcess, fork, ForkOptions, spawn } from 'child_process'
import EventEmitter from 'events'
import path from 'path'
Expand Down Expand Up @@ -50,10 +50,6 @@ interface SerializedLoadConfigReply {
requires: string[]
}

export interface DebugData {
filePreprocessorHandlerText?: string
}

/**
* The ProjectConfigIpc is an EventEmitter wrapping the childProcess,
* adding a "send" method for sending events from the parent process into the childProcess,
Expand Down
4 changes: 2 additions & 2 deletions packages/data-context/src/data/ProjectConfigManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CypressError, getError } from '@packages/errors'
import { DebugData, PluginIpcHandler, LoadConfigReply, ProjectConfigIpc, SetupNodeEventsReply } from './ProjectConfigIpc'
import { PluginIpcHandler, LoadConfigReply, ProjectConfigIpc, SetupNodeEventsReply } from './ProjectConfigIpc'
import assert from 'assert'
import type { AllModeOptions, FullConfig, TestingType } from '@packages/types'
import type { AllModeOptions, FullConfig, TestingType, DebugData } from '@packages/types'
import debugLib from 'debug'
import path from 'path'
import _ from 'lodash'
Expand Down
6 changes: 4 additions & 2 deletions packages/server/lib/cloud/studio/StudioLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/init
import crypto from 'crypto'
import { logError } from '@packages/stderr-filtering'
import { isNonRetriableCertErrorCode } from '../network/non_retriable_cert_error_codes'
import type { DebugData } from '@packages/types'

const debug = Debug('cypress:server:studio-lifecycle-manager')
const routes = require('../routes')
Expand Down Expand Up @@ -179,7 +180,7 @@ export class StudioLifecycleManager {
}: {
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
debugData?: DebugData
getProjectOptions: Required<StudioServerOptions>['getProjectOptions']
}): Promise<StudioManager> {
let studioPath: string
Expand Down Expand Up @@ -277,6 +278,7 @@ export class StudioLifecycleManager {
},
manifest,
getProjectOptions,
debugData,
})

telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END)
Expand Down Expand Up @@ -349,7 +351,7 @@ export class StudioLifecycleManager {
}: {
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
debugData?: DebugData
getProjectOptions: Required<StudioServerOptions>['getProjectOptions']
}) {
// Don't setup a watcher if the studio bundle is NOT local
Expand Down
39 changes: 32 additions & 7 deletions packages/server/lib/cloud/studio/studio.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioAddSocketListenersOptions, StudioServerOptions, StudioCDPClient } from '@packages/types'
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, StudioConfig, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioAddSocketListenersOptions, StudioServerOptions, StudioCDPClient } from '@packages/types'
import type { Router } from 'express'
import Debug from 'debug'
import { requireScript } from '../require_script'
import path from 'path'
import crypto, { BinaryLike } from 'crypto'
import { StudioElectron } from './StudioElectron'
import exception from '../exception'
import type { DebugData } from '@packages/types'

interface StudioServer { default: StudioServerDefaultShape }

Expand All @@ -16,6 +17,7 @@ interface SetupOptions {
cloudApi: StudioCloudApi
manifest: Record<string, string>
getProjectOptions: StudioServerOptions['getProjectOptions']
debugData?: DebugData
}

const debug = Debug('cypress:server:studio')
Expand All @@ -26,7 +28,7 @@ export class StudioManager implements StudioManagerShape {
private _studioServer: StudioServerShape | undefined
private _studioElectron: StudioElectron | undefined

async setup ({ script, studioPath, studioHash, cloudApi, manifest, getProjectOptions }: SetupOptions): Promise<void> {
async setup ({ script, studioPath, studioHash, cloudApi, manifest, getProjectOptions, debugData }: SetupOptions): Promise<void> {
const { createStudioServer } = requireScript<StudioServer>(script).default

this._studioServer = await createStudioServer({
Expand All @@ -47,6 +49,7 @@ export class StudioManager implements StudioManagerShape {
return actualHash === expectedHash
},
getProjectOptions,
debugData,
})

this.status = 'ENABLED'
Expand Down Expand Up @@ -75,6 +78,26 @@ export class StudioManager implements StudioManagerShape {
return !!(await this.invokeAsync('canAccessStudioAI', { isEssential: true }, browser))
}

async getStudioConfig (browser: Cypress.Browser): Promise<StudioConfig> {
const config = await this.invokeAsync('getStudioConfig', { isEssential: true }, browser)

if (config === undefined) {
throw new Error('Studio is not available: server not initialized or an error occurred')
}

return config
}

getCachedStudioConfig (): StudioConfig {
const config = this.invokeSync('getCachedStudioConfig', { isEssential: true })

if (config === undefined) {
throw new Error('Studio is not available: server not initialized or an error occurred')
}

return config
}

connectToBrowser (target: StudioCDPClient): void {
if (this._studioServer) {
return this.invokeSync('connectToBrowser', { isEssential: true }, target)
Expand Down Expand Up @@ -187,11 +210,13 @@ export class StudioManager implements StudioManagerShape {
}
}

// Helper types for invokeSync / invokeAsync
// Helper types for invokeSync / invokeAsync (only method keys; exclude e.g. sessionId)
type StudioServerMethodKey = Exclude<keyof StudioServerShape, 'sessionId'>

type StudioServerSyncMethods = {
[K in keyof StudioServerShape]: ReturnType<StudioServerShape[K]> extends Promise<any> ? never : K
}[keyof StudioServerShape]
[K in StudioServerMethodKey]: ReturnType<StudioServerShape[K]> extends Promise<any> ? never : K
}[StudioServerMethodKey]

type StudioServerAsyncMethods = {
[K in keyof StudioServerShape]: ReturnType<StudioServerShape[K]> extends Promise<any> ? K : never
}[keyof StudioServerShape]
[K in StudioServerMethodKey]: ReturnType<StudioServerShape[K]> extends Promise<any> ? K : never
}[StudioServerMethodKey]
Loading
Loading