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
107 changes: 107 additions & 0 deletions packages/server/lib/cloud/api/cy-prompt/report_cy-prompt_error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { CyPromptCloudApi } from '@packages/types/src/cy-prompt/cy-prompt-server-types'
import Debug from 'debug'
import { stripPath } from '../../strip_path'
const debug = Debug('cypress:server:cloud:api:cy-prompt:report_cy-prompt_error')

export interface ReportCyPromptErrorOptions {
cloudApi: CyPromptCloudApi
cyPromptHash: string | undefined
projectSlug: string | undefined
error: unknown
cyPromptMethod: string
cyPromptMethodArgs?: unknown[]
}

interface CyPromptError {
name: string
stack: string
message: string
cyPromptMethod: string
cyPromptMethodArgs?: string
}

interface CyPromptErrorPayload {
cyPromptHash: string | undefined
projectSlug: string | undefined
errors: CyPromptError[]
}

export function reportCyPromptError ({
cloudApi,
cyPromptHash,
projectSlug,
error,
cyPromptMethod,
cyPromptMethodArgs,
}: ReportCyPromptErrorOptions): void {
debug('Error reported:', error)

// When developing locally, do not send to Sentry, but instead log to console.
if (
process.env.CYPRESS_LOCAL_CY_PROMPT_PATH ||
process.env.NODE_ENV === 'development' ||
process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF
) {
// eslint-disable-next-line no-console
console.error(`Error in ${cyPromptMethod}:`, error)

return
}

let errorObject: Error

if (!(error instanceof Error)) {
errorObject = new Error(String(error))
} else {
errorObject = error
}

let cyPromptMethodArgsString: string | undefined

if (cyPromptMethodArgs) {
try {
cyPromptMethodArgsString = JSON.stringify({
args: cyPromptMethodArgs,
})
} catch (e: unknown) {
cyPromptMethodArgsString = `Unknown args: ${e}`
}
}

try {
const payload: CyPromptErrorPayload = {
cyPromptHash,
projectSlug,
errors: [{
name: stripPath(errorObject.name ?? `Unknown name`),
stack: stripPath(errorObject.stack ?? `Unknown stack`),
message: stripPath(errorObject.message ?? `Unknown message`),
cyPromptMethod,
cyPromptMethodArgs: cyPromptMethodArgsString,
}],
}

cloudApi.CloudRequest.post(
`${cloudApi.cloudUrl}/cy-prompt/errors`,
payload,
{
headers: {
'Content-Type': 'application/json',
...cloudApi.cloudHeaders,
},
},
).catch((e: unknown) => {
debug(
`Error calling CyPromptManager.reportError: %o, original error %o`,
e,
error,
)
})
} catch (e: unknown) {
debug(
`Error calling CyPromptManager.reportError: %o, original error %o`,
e,
error,
)
}
}
52 changes: 26 additions & 26 deletions packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import chokidar from 'chokidar'
import { getCloudMetadata } from '../get_cloud_metadata'
import type { CyPromptAuthenticatedUserShape } from '@packages/types'
import crypto from 'crypto'
import { reportCyPromptError } from '../api/cy-prompt/report_cy-prompt_error'

const debug = Debug('cypress:server:cy-prompt-lifecycle-manager')

Expand All @@ -26,6 +27,7 @@ export class CyPromptLifecycleManager {
}>
private cyPromptManager?: CyPromptManager
private listeners: ((cyPromptManager: CyPromptManager) => void)[] = []
private cyPromptHash: string | undefined

/**
* Initialize the cy prompt manager.
Expand Down Expand Up @@ -66,25 +68,24 @@ export class CyPromptLifecycleManager {
}).catch(async (error) => {
debug('Error during cy prompt manager setup: %o', error)

// const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
// const cloudUrl = ctx.cloud.getCloudUrl(cloudEnv)
// const cloudHeaders = await ctx.cloud.additionalHeaders()

// TODO: reportCyPromptError
// reportCyPromptError({
// cloudApi: {
// cloudUrl,
// cloudHeaders,
// CloudRequest,
// isRetryableError,
// asyncRetry,
// },
// cyPromptHash: projectId,
// projectSlug: cfg.projectId,
// error,
// cyPromptMethod: 'initializeCyPromptManager',
// cyPromptMethodArgs: [],
// })
const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
const cloudUrl = ctx.cloud.getCloudUrl(cloudEnv)
const cloudHeaders = await ctx.cloud.additionalHeaders()

reportCyPromptError({
cloudApi: {
cloudUrl,
cloudHeaders,
CloudRequest,
isRetryableError,
asyncRetry,
},
cyPromptHash: this.cyPromptHash,
projectSlug: (await ctx.project.getConfig()).projectId || undefined,
error,
cyPromptMethod: 'initializeCyPromptManager',
cyPromptMethodArgs: [],
})

// Clean up any registered listeners
this.listeners = []
Expand Down Expand Up @@ -123,7 +124,6 @@ export class CyPromptLifecycleManager {
key?: string
}>
}): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> {
let cyPromptHash: string
let cyPromptPath: string
let manifest: Record<string, string>

Expand All @@ -135,10 +135,10 @@ export class CyPromptLifecycleManager {

if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
// 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)
this.cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] as string
cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', this.cyPromptHash)

let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(cyPromptHash)
let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(this.cyPromptHash)

if (!hashLoadingPromise) {
hashLoadingPromise = ensureCyPromptBundle({
Expand All @@ -147,13 +147,13 @@ export class CyPromptLifecycleManager {
cyPromptPath,
})

CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise)
CyPromptLifecycleManager.hashLoadingMap.set(this.cyPromptHash, hashLoadingPromise)
}

manifest = await hashLoadingPromise
} else {
cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH
cyPromptHash = 'local'
this.cyPromptHash = 'local'
manifest = {}
}

Expand Down Expand Up @@ -181,7 +181,7 @@ export class CyPromptLifecycleManager {
await cyPromptManager.setup({
script,
cyPromptPath,
cyPromptHash,
cyPromptHash: this.cyPromptHash,
cloudApi: {
cloudUrl,
CloudRequest,
Expand Down
16 changes: 8 additions & 8 deletions packages/server/lib/cloud/studio/StudioLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class StudioLifecycleManager {
private listeners: ((studioManager: StudioManager) => void)[] = []
private ctx?: DataContext
private lastStatus?: StudioStatus
private studioHash: string | undefined

public get cloudStudioRequested () {
return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH)
Expand Down Expand Up @@ -91,7 +92,7 @@ export class StudioLifecycleManager {
isRetryableError,
asyncRetry,
},
studioHash: projectId,
studioHash: this.studioHash,
projectSlug: cfg.projectId,
error,
studioMethod: 'initializeStudioManager',
Expand Down Expand Up @@ -157,7 +158,6 @@ export class StudioLifecycleManager {
debugData: any
}): Promise<StudioManager> {
let studioPath: string
let studioHash: string
let manifest: Record<string, string>

initializeTelemetryReporter({
Expand All @@ -177,10 +177,10 @@ export class StudioLifecycleManager {
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_START)
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
// The studio hash is the last part of the studio URL, after the last slash and before the extension
studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0]
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash)
this.studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] as string
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', this.studioHash)

let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(studioHash)
let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(this.studioHash)

if (!hashLoadingPromise) {
hashLoadingPromise = ensureStudioBundle({
Expand All @@ -189,13 +189,13 @@ export class StudioLifecycleManager {
projectId,
})

StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
StudioLifecycleManager.hashLoadingMap.set(this.studioHash, hashLoadingPromise)
}

manifest = await hashLoadingPromise
} else {
studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH
studioHash = 'local'
this.studioHash = 'local'
manifest = {}
}

Expand Down Expand Up @@ -227,7 +227,7 @@ export class StudioLifecycleManager {
await studioManager.setup({
script,
studioPath,
studioHash,
studioHash: this.studioHash,
projectSlug: projectId,
cloudApi: {
cloudUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import os from 'os'
import { CloudRequest } from '../../../../lib/cloud/api/cloud_request'
import { isRetryableError } from '../../../../lib/cloud/network/is_retryable_error'
import { asyncRetry } from '../../../../lib/util/async_retry'
import * as reportCyPromptErrorPath from '../../../../lib/cloud/api/cy-prompt/report_cy-prompt_error'

describe('CyPromptLifecycleManager', () => {
let cyPromptLifecycleManager: CyPromptLifecycleManager
Expand All @@ -24,12 +25,14 @@ describe('CyPromptLifecycleManager', () => {
let watcherStub: sinon.SinonStub = sinon.stub()
let watcherOnStub: sinon.SinonStub = sinon.stub()
let watcherCloseStub: sinon.SinonStub = sinon.stub()
let reportCyPromptErrorStub: sinon.SinonStub
const mockContents: string = 'console.log("cy-prompt script")'

beforeEach(() => {
postCyPromptSessionStub = sinon.stub()
cyPromptManagerSetupStub = sinon.stub()
ensureCyPromptBundleStub = sinon.stub()
reportCyPromptErrorStub = sinon.stub()
cyPromptStatusChangeEmitterStub = sinon.stub()
mockCyPromptManager = {
status: 'INITIALIZED',
Expand Down Expand Up @@ -100,6 +103,8 @@ describe('CyPromptLifecycleManager', () => {
postCyPromptSessionStub.resolves({
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
})

reportCyPromptErrorStub = sinon.stub(reportCyPromptErrorPath, 'reportCyPromptError').resolves()
})

afterEach(() => {
Expand Down Expand Up @@ -322,6 +327,23 @@ describe('CyPromptLifecycleManager', () => {
const { error } = await cyPromptPromise

expect(error.message).to.equal('Expected hash for cy prompt server script not found in manifest')

expect(reportCyPromptErrorStub).to.be.calledWith({
cloudApi: {
cloudUrl: 'https://cloud.cypress.io',
CloudRequest,
isRetryableError,
asyncRetry,
cloudHeaders: {
'Authorization': 'Bearer test-token',
},
},
cyPromptHash: 'abc',
projectSlug: 'test-project-id',
error,
cyPromptMethod: 'initializeCyPromptManager',
cyPromptMethodArgs: [],
})
})

it('throws an error when the cy-prompt server script is wrong in the manifest', async () => {
Expand Down Expand Up @@ -350,6 +372,23 @@ describe('CyPromptLifecycleManager', () => {
const { error } = await cyPromptPromise

expect(error.message).to.equal('Invalid hash for cy prompt server script')

expect(reportCyPromptErrorStub).to.be.calledWith({
cloudApi: {
cloudUrl: 'https://cloud.cypress.io',
CloudRequest,
isRetryableError,
asyncRetry,
cloudHeaders: {
'Authorization': 'Bearer test-token',
},
},
cyPromptHash: 'abc',
projectSlug: 'test-project-id',
error,
cyPromptMethod: 'initializeCyPromptManager',
cyPromptMethodArgs: [],
})
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ describe('StudioLifecycleManager', () => {
expect(reportStudioErrorStub).to.be.calledOnce
expect(reportStudioErrorStub).to.be.calledWithMatch({
cloudApi: sinon.match.object,
studioHash: 'test-project-id',
studioHash: 'abc',
projectSlug: 'abc123',
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Expected hash for studio server script not found in manifest')),
studioMethod: 'initializeStudioManager',
Expand Down Expand Up @@ -573,7 +573,7 @@ describe('StudioLifecycleManager', () => {
expect(reportStudioErrorStub).to.be.calledOnce
expect(reportStudioErrorStub).to.be.calledWithMatch({
cloudApi: sinon.match.object,
studioHash: 'test-project-id',
studioHash: 'abc',
projectSlug: 'abc123',
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Invalid hash for studio server script')),
studioMethod: 'initializeStudioManager',
Expand Down Expand Up @@ -623,7 +623,7 @@ describe('StudioLifecycleManager', () => {
expect(reportStudioErrorStub).to.be.calledOnce
expect(reportStudioErrorStub).to.be.calledWithMatch({
cloudApi: sinon.match.object,
studioHash: 'test-project-id',
studioHash: 'abc',
projectSlug: 'abc123',
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Test error')),
studioMethod: 'initializeStudioManager',
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/cy-prompt/cy-prompt-server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface CyPromptCloudApi {
cloudUrl: string
CloudRequest: AxiosInstance
isRetryableError: (err: unknown) => boolean
cloudHeaders?: Record<string, string>
asyncRetry: AsyncRetry
}

Expand Down