mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-18 22:28:38 -05:00
internal: (studio) improve error reporting (#31546)
* internal: (studio) improvements to how studio can access the protocol database * fix * fix build * refactor due to downstream changes * feat: capture errors with studio * types * add tests * fix types * fix typescript * strip path * fix tests
This commit is contained in:
+27
-13
@@ -1,21 +1,21 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { ensureDir, copy, readFile } from 'fs-extra'
|
||||
import { StudioManager } from '../studio'
|
||||
import { StudioManager } from '../../studio'
|
||||
import tar from 'tar'
|
||||
import { verifySignatureFromFile } from '../encryption'
|
||||
import { verifySignatureFromFile } from '../../encryption'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import fetch from 'cross-fetch'
|
||||
import { agent } from '@packages/network'
|
||||
import { asyncRetry, linearDelay } from '../../util/async_retry'
|
||||
import { isRetryableError } from '../network/is_retryable_error'
|
||||
import { PUBLIC_KEY_VERSION } from '../constants'
|
||||
import { CloudRequest } from './cloud_request'
|
||||
import { asyncRetry, linearDelay } from '../../../util/async_retry'
|
||||
import { isRetryableError } from '../../network/is_retryable_error'
|
||||
import { PUBLIC_KEY_VERSION } from '../../constants'
|
||||
import { CloudRequest } from '../cloud_request'
|
||||
import type { CloudDataSource } from '@packages/data-context/src/sources'
|
||||
|
||||
const pkg = require('@packages/root')
|
||||
const routes = require('../routes')
|
||||
const routes = require('../../routes')
|
||||
|
||||
const _delay = linearDelay(500)
|
||||
|
||||
@@ -121,17 +121,19 @@ export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?
|
||||
export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource }: { projectId?: string, cloudDataSource: CloudDataSource }): Promise<StudioManager> => {
|
||||
let script: string
|
||||
|
||||
const cloudEnv = (process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
|
||||
const cloudUrl = cloudDataSource.getCloudUrl(cloudEnv)
|
||||
const cloudHeaders = await cloudDataSource.additionalHeaders()
|
||||
|
||||
let studioHash: string | undefined
|
||||
|
||||
try {
|
||||
const { studioHash } = await retrieveAndExtractStudioBundle({ projectId })
|
||||
({ studioHash } = await retrieveAndExtractStudioBundle({ projectId }))
|
||||
|
||||
script = await readFile(serverFilePath, 'utf8')
|
||||
|
||||
const studioManager = new StudioManager()
|
||||
|
||||
const cloudEnv = (process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
|
||||
const cloudUrl = cloudDataSource.getCloudUrl(cloudEnv)
|
||||
const cloudHeaders = await cloudDataSource.additionalHeaders()
|
||||
|
||||
await studioManager.setup({
|
||||
script,
|
||||
studioPath,
|
||||
@@ -156,7 +158,19 @@ export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource
|
||||
actualError = error
|
||||
}
|
||||
|
||||
return StudioManager.createInErrorManager(actualError)
|
||||
return StudioManager.createInErrorManager({
|
||||
cloudApi: {
|
||||
cloudUrl,
|
||||
cloudHeaders,
|
||||
CloudRequest,
|
||||
isRetryableError,
|
||||
asyncRetry,
|
||||
},
|
||||
studioHash,
|
||||
projectSlug: projectId,
|
||||
error: actualError,
|
||||
studioMethod: 'getAndInitializeStudioManager',
|
||||
})
|
||||
} finally {
|
||||
await fs.promises.rm(bundlePath, { force: true })
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { StudioCloudApi } from '@packages/types/src/studio/studio-server-types'
|
||||
import Debug from 'debug'
|
||||
import { stripPath } from '../../strip_path'
|
||||
|
||||
const debug = Debug('cypress:server:cloud:api:studio:report_studio_errors')
|
||||
|
||||
export interface ReportStudioErrorOptions {
|
||||
cloudApi: StudioCloudApi
|
||||
studioHash: string | undefined
|
||||
projectSlug: string | undefined
|
||||
error: unknown
|
||||
studioMethod: string
|
||||
studioMethodArgs?: unknown[]
|
||||
}
|
||||
|
||||
interface StudioError {
|
||||
name: string
|
||||
stack: string
|
||||
message: string
|
||||
studioMethod: string
|
||||
studioMethodArgs?: string
|
||||
}
|
||||
|
||||
interface StudioErrorPayload {
|
||||
studioHash: string | undefined
|
||||
projectSlug: string | undefined
|
||||
errors: StudioError[]
|
||||
}
|
||||
|
||||
export function reportStudioError ({
|
||||
cloudApi,
|
||||
studioHash,
|
||||
projectSlug,
|
||||
error,
|
||||
studioMethod,
|
||||
studioMethodArgs,
|
||||
}: ReportStudioErrorOptions): void {
|
||||
debug('Error reported:', error)
|
||||
|
||||
// When developing locally, we want to throw the error so we can see it in the console
|
||||
if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
|
||||
throw error
|
||||
}
|
||||
|
||||
let errorObject: Error
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
errorObject = new Error(String(error))
|
||||
} else {
|
||||
errorObject = error
|
||||
}
|
||||
|
||||
let studioMethodArgsString: string | undefined
|
||||
|
||||
if (studioMethodArgs) {
|
||||
try {
|
||||
studioMethodArgsString = JSON.stringify({
|
||||
args: studioMethodArgs,
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
studioMethodArgsString = `Unknown args: ${e}`
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: StudioErrorPayload = {
|
||||
studioHash,
|
||||
projectSlug,
|
||||
errors: [{
|
||||
name: stripPath(errorObject.name ?? `Unknown name`),
|
||||
stack: stripPath(errorObject.stack ?? `Unknown stack`),
|
||||
message: stripPath(errorObject.message ?? `Unknown message`),
|
||||
studioMethod,
|
||||
studioMethodArgs: studioMethodArgsString,
|
||||
}],
|
||||
}
|
||||
|
||||
cloudApi.CloudRequest.post(
|
||||
`${cloudApi.cloudUrl}/studio/errors`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...cloudApi.cloudHeaders,
|
||||
},
|
||||
},
|
||||
).catch((e: unknown) => {
|
||||
debug(
|
||||
`Error calling StudioManager.reportError: %o, original error %o`,
|
||||
e,
|
||||
error,
|
||||
)
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
debug(
|
||||
`Error calling StudioManager.reportError: %o, original error %o`,
|
||||
e,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,7 @@ const pkg = require('@packages/root')
|
||||
const api = require('./api').default
|
||||
const user = require('./user')
|
||||
const system = require('../util/system')
|
||||
|
||||
// strip everything but the file name to remove any sensitive
|
||||
// data in the path
|
||||
const pathRe = /'?((\/|\\+|[a-z]:\\)[^\s']+)+'?/ig
|
||||
const pathSepRe = /[\/\\]+/
|
||||
const stripPath = (text) => {
|
||||
return (text || '').replace(pathRe, (path) => {
|
||||
const fileName = _.last(path.split(pathSepRe)) || ''
|
||||
|
||||
return `<stripped-path>${fileName}`
|
||||
})
|
||||
}
|
||||
const { stripPath } = require('./strip_path')
|
||||
|
||||
export = {
|
||||
getErr (err: Error) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { last } from 'lodash'
|
||||
|
||||
// strip everything but the file name to remove any sensitive
|
||||
// data in the path
|
||||
const pathRe = /'?((\/|\\+|[a-z]:\\)[^\s']+)+'?/ig
|
||||
const pathSepRe = /[\/\\]+/
|
||||
|
||||
export const stripPath = (text: string) => {
|
||||
return (text || '').replace(pathRe, (path) => {
|
||||
const fileName = last(path.split(pathSepRe)) || ''
|
||||
|
||||
return `<stripped-path>${fileName}`
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { StudioErrorReport, StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions } from '@packages/types'
|
||||
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions } from '@packages/types'
|
||||
import type { Router } from 'express'
|
||||
import type { Socket } from 'socket.io'
|
||||
import fetch from 'cross-fetch'
|
||||
import pkg from '@packages/root'
|
||||
import os from 'os'
|
||||
import { agent } from '@packages/network'
|
||||
import Debug from 'debug'
|
||||
import { requireScript } from './require_script'
|
||||
import path from 'path'
|
||||
import { reportStudioError, ReportStudioErrorOptions } from './api/studio/report_studio_error'
|
||||
|
||||
interface StudioServer { default: StudioServerDefaultShape }
|
||||
|
||||
@@ -20,21 +17,26 @@ interface SetupOptions {
|
||||
}
|
||||
|
||||
const debug = Debug('cypress:server:studio')
|
||||
const routes = require('./routes')
|
||||
|
||||
export class StudioManager implements StudioManagerShape {
|
||||
status: StudioStatus = 'NOT_INITIALIZED'
|
||||
isProtocolEnabled: boolean = false
|
||||
protocolManager: ProtocolManagerShape | undefined
|
||||
private _studioServer: StudioServerShape | undefined
|
||||
private _studioHash: string | undefined
|
||||
|
||||
static createInErrorManager (error: Error): StudioManager {
|
||||
static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs }: ReportStudioErrorOptions): StudioManager {
|
||||
const manager = new StudioManager()
|
||||
|
||||
manager.status = 'IN_ERROR'
|
||||
|
||||
manager.reportError(error).catch(() => { })
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash,
|
||||
projectSlug,
|
||||
error,
|
||||
studioMethod,
|
||||
studioMethodArgs,
|
||||
})
|
||||
|
||||
return manager
|
||||
}
|
||||
@@ -43,13 +45,13 @@ export class StudioManager implements StudioManagerShape {
|
||||
const { createStudioServer } = requireScript<StudioServer>(script).default
|
||||
|
||||
this._studioServer = await createStudioServer({
|
||||
studioHash,
|
||||
studioPath,
|
||||
projectSlug,
|
||||
cloudApi,
|
||||
betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')),
|
||||
})
|
||||
|
||||
this._studioHash = studioHash
|
||||
this.status = 'INITIALIZED'
|
||||
}
|
||||
|
||||
@@ -77,32 +79,11 @@ export class StudioManager implements StudioManagerShape {
|
||||
await this.invokeAsync('destroy', { isEssential: true })
|
||||
}
|
||||
|
||||
private async reportError (error: Error): Promise<void> {
|
||||
reportError (error: unknown, studioMethod: string, ...studioMethodArgs: unknown[]): void {
|
||||
try {
|
||||
const payload: StudioErrorReport = {
|
||||
studioHash: this._studioHash,
|
||||
errors: [{
|
||||
name: error.name ?? `Unknown name`,
|
||||
stack: error.stack ?? `Unknown stack`,
|
||||
message: error.message ?? `Unknown message`,
|
||||
}],
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload)
|
||||
|
||||
await fetch(routes.apiRoutes.studioErrors() as string, {
|
||||
// @ts-expect-error - this is supported
|
||||
agent,
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': pkg.version,
|
||||
'x-os-name': os.platform(),
|
||||
'x-arch': os.arch(),
|
||||
},
|
||||
})
|
||||
this._studioServer?.reportError(error, studioMethod, ...studioMethodArgs)
|
||||
} catch (e) {
|
||||
// If we fail to report the error, we shouldn't try and report it again
|
||||
debug(`Error calling StudioManager.reportError: %o, original error %o`, e, error)
|
||||
}
|
||||
}
|
||||
@@ -129,8 +110,7 @@ export class StudioManager implements StudioManagerShape {
|
||||
}
|
||||
|
||||
this.status = 'IN_ERROR'
|
||||
// Call and forget this, we don't want to block the main thread
|
||||
this.reportError(actualError).catch(() => { })
|
||||
this.reportError(actualError, method, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +136,8 @@ export class StudioManager implements StudioManagerShape {
|
||||
}
|
||||
|
||||
this.status = 'IN_ERROR'
|
||||
// Call and forget this, we don't want to block the main thread
|
||||
this.reportError(actualError).catch(() => { })
|
||||
this.reportError(actualError, method, ...args)
|
||||
|
||||
// TODO: Figure out errors
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import ProtocolManager from './cloud/protocol'
|
||||
import { ServerBase } from './server-base'
|
||||
import type Protocol from 'devtools-protocol'
|
||||
import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/service-worker-manager'
|
||||
import { getAndInitializeStudioManager } from './cloud/api/get_and_initialize_studio_manager'
|
||||
import { getAndInitializeStudioManager } from './cloud/api/studio/get_and_initialize_studio_manager'
|
||||
import api from './cloud/api'
|
||||
import type { StudioManager } from './cloud/studio'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
@@ -17,10 +17,14 @@ class StudioServer implements StudioServerShape {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
reportError (error: Error, method: string, ...args: any[]): void {
|
||||
// This is a test implementation that does nothing
|
||||
}
|
||||
|
||||
destroy (): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
|
||||
addSocketListeners (socket: Socket): void {
|
||||
// This is a test implementation that does nothing
|
||||
}
|
||||
|
||||
+43
-13
@@ -1,13 +1,13 @@
|
||||
import { Readable, Writable } from 'stream'
|
||||
import { proxyquire, sinon } from '../../../spec_helper'
|
||||
import { HttpError } from '../../../../lib/cloud/network/http_error'
|
||||
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 { proxyquire, sinon } from '../../../../spec_helper'
|
||||
import { HttpError } from '../../../../../lib/cloud/network/http_error'
|
||||
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 { CloudDataSource } from '@packages/data-context/src/sources'
|
||||
|
||||
describe('getAndInitializeStudioManager', () => {
|
||||
let getAndInitializeStudioManager: typeof import('@packages/server/lib/cloud/api/get_and_initialize_studio_manager').getAndInitializeStudioManager
|
||||
let getAndInitializeStudioManager: typeof import('@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager').getAndInitializeStudioManager
|
||||
let rmStub: sinon.SinonStub = sinon.stub()
|
||||
let ensureStub: sinon.SinonStub = sinon.stub()
|
||||
let copyStub: sinon.SinonStub = sinon.stub()
|
||||
@@ -34,7 +34,7 @@ describe('getAndInitializeStudioManager', () => {
|
||||
createInErrorManagerStub = sinon.stub()
|
||||
studioManagerSetupStub = sinon.stub()
|
||||
|
||||
getAndInitializeStudioManager = (proxyquire('../lib/cloud/api/get_and_initialize_studio_manager', {
|
||||
getAndInitializeStudioManager = (proxyquire('../lib/cloud/api/studio/get_and_initialize_studio_manager', {
|
||||
fs: {
|
||||
promises: {
|
||||
rm: rmStub.resolves(),
|
||||
@@ -54,10 +54,10 @@ describe('getAndInitializeStudioManager', () => {
|
||||
tar: {
|
||||
extract: extractStub.resolves(),
|
||||
},
|
||||
'../encryption': {
|
||||
'../../encryption': {
|
||||
verifySignatureFromFile: verifySignatureFromFileStub,
|
||||
},
|
||||
'../studio': {
|
||||
'../../studio': {
|
||||
StudioManager: class StudioManager {
|
||||
static createInErrorManager = createInErrorManagerStub
|
||||
setup = (...options) => studioManagerSetupStub(...options)
|
||||
@@ -67,7 +67,7 @@ describe('getAndInitializeStudioManager', () => {
|
||||
'@packages/root': {
|
||||
version: '1.2.3',
|
||||
},
|
||||
}) as typeof import('@packages/server/lib/cloud/api/get_and_initialize_studio_manager')).getAndInitializeStudioManager
|
||||
}) as typeof import('@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager')).getAndInitializeStudioManager
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -314,7 +314,19 @@ describe('getAndInitializeStudioManager', () => {
|
||||
encrypt: 'signed',
|
||||
})
|
||||
|
||||
expect(createInErrorManagerStub).to.be.calledWithMatch(sinon.match.instanceOf(AggregateError))
|
||||
expect(createInErrorManagerStub).to.be.calledWithMatch({
|
||||
error: sinon.match.instanceOf(AggregateError),
|
||||
cloudApi: {
|
||||
cloudUrl: 'http://localhost:1234',
|
||||
cloudHeaders: {
|
||||
a: 'b',
|
||||
c: 'd',
|
||||
},
|
||||
},
|
||||
studioHash: undefined,
|
||||
projectSlug: '12345',
|
||||
studioMethod: 'getAndInitializeStudioManager',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error and returns a studio manager in error state if the signature verification fails', async () => {
|
||||
@@ -367,7 +379,16 @@ describe('getAndInitializeStudioManager', () => {
|
||||
})
|
||||
|
||||
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/bundle.tar', '159')
|
||||
expect(createInErrorManagerStub).to.be.calledWithMatch(sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Unable to verify studio signature')))
|
||||
expect(createInErrorManagerStub).to.be.calledWithMatch({
|
||||
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Unable to verify studio signature')),
|
||||
cloudApi: {
|
||||
cloudUrl: 'http://localhost:1234',
|
||||
cloudHeaders: { a: 'b', c: 'd' },
|
||||
},
|
||||
studioHash: undefined,
|
||||
projectSlug: '12345',
|
||||
studioMethod: 'getAndInitializeStudioManager',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error if there is no signature in the response headers', async () => {
|
||||
@@ -397,7 +418,16 @@ describe('getAndInitializeStudioManager', () => {
|
||||
|
||||
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
|
||||
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
|
||||
expect(createInErrorManagerStub).to.be.calledWithMatch(sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Unable to get studio signature')))
|
||||
expect(createInErrorManagerStub).to.be.calledWithMatch({
|
||||
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Unable to get studio signature')),
|
||||
cloudApi: {
|
||||
cloudUrl: 'http://localhost:1234',
|
||||
cloudHeaders: { a: 'b', c: 'd' },
|
||||
},
|
||||
studioHash: undefined,
|
||||
projectSlug: '12345',
|
||||
studioMethod: 'getAndInitializeStudioManager',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import { expect } from 'chai'
|
||||
import { sinon } from '../../../../spec_helper'
|
||||
import { reportStudioError } from '@packages/server/lib/cloud/api/studio/report_studio_error'
|
||||
|
||||
describe('lib/cloud/api/studio/report_studio_error', () => {
|
||||
let cloudRequestStub: sinon.SinonStub
|
||||
let cloudApi: any
|
||||
|
||||
beforeEach(() => {
|
||||
cloudRequestStub = sinon.stub()
|
||||
cloudApi = {
|
||||
cloudUrl: 'http://localhost:1234',
|
||||
cloudHeaders: { 'x-cypress-version': '1.2.3' },
|
||||
CloudRequest: {
|
||||
post: cloudRequestStub,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('reportStudioError', () => {
|
||||
it('throws error when CYPRESS_LOCAL_STUDIO_PATH is set', () => {
|
||||
process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
|
||||
const error = new Error('test error')
|
||||
|
||||
expect(() => {
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
error,
|
||||
studioMethod: 'testMethod',
|
||||
})
|
||||
}).to.throw('test error')
|
||||
})
|
||||
|
||||
it('converts non-Error objects to Error', () => {
|
||||
const error = 'string error'
|
||||
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
error,
|
||||
studioMethod: 'testMethod',
|
||||
})
|
||||
|
||||
expect(cloudRequestStub).to.be.calledWithMatch(
|
||||
'http://localhost:1234/studio/errors',
|
||||
{
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
errors: [{
|
||||
name: 'Error',
|
||||
message: 'string error',
|
||||
stack: sinon.match((stack) => stack.includes('<stripped-path>report_studio_error_spec.ts')),
|
||||
studioMethod: 'testMethod',
|
||||
studioMethodArgs: undefined,
|
||||
}],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('handles Error objects correctly', () => {
|
||||
const error = new Error('test error')
|
||||
|
||||
error.stack = 'test stack'
|
||||
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
error,
|
||||
studioMethod: 'testMethod',
|
||||
})
|
||||
|
||||
expect(cloudRequestStub).to.be.calledWithMatch(
|
||||
'http://localhost:1234/studio/errors',
|
||||
{
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
errors: [{
|
||||
name: 'Error',
|
||||
message: 'test error',
|
||||
stack: 'test stack',
|
||||
studioMethod: 'testMethod',
|
||||
studioMethodArgs: undefined,
|
||||
}],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('includes studioMethodArgs when provided', () => {
|
||||
const error = new Error('test error')
|
||||
const args = ['arg1', { key: 'value' }]
|
||||
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
error,
|
||||
studioMethod: 'testMethod',
|
||||
studioMethodArgs: args,
|
||||
})
|
||||
|
||||
expect(cloudRequestStub).to.be.calledWithMatch(
|
||||
'http://localhost:1234/studio/errors',
|
||||
{
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
errors: [{
|
||||
name: 'Error',
|
||||
message: 'test error',
|
||||
stack: sinon.match((stack) => stack.includes('<stripped-path>report_studio_error_spec.ts')),
|
||||
studioMethod: 'testMethod',
|
||||
studioMethodArgs: JSON.stringify({ args }),
|
||||
}],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('handles errors in JSON.stringify for studioMethodArgs', () => {
|
||||
const error = new Error('test error')
|
||||
const circularObj: any = {}
|
||||
|
||||
circularObj.self = circularObj
|
||||
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
error,
|
||||
studioMethod: 'testMethod',
|
||||
studioMethodArgs: [circularObj],
|
||||
})
|
||||
|
||||
expect(cloudRequestStub).to.be.calledWithMatch(
|
||||
'http://localhost:1234/studio/errors',
|
||||
{
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
errors: [{
|
||||
name: 'Error',
|
||||
message: 'test error',
|
||||
stack: sinon.match((stack) => stack.includes('<stripped-path>report_studio_error_spec.ts')),
|
||||
studioMethod: 'testMethod',
|
||||
studioMethodArgs: sinon.match(/Unknown args/),
|
||||
}],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('handles errors in CloudRequest.post', () => {
|
||||
const error = new Error('test error')
|
||||
const postError = new Error('post error')
|
||||
|
||||
cloudRequestStub.rejects(postError)
|
||||
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
error,
|
||||
studioMethod: 'testMethod',
|
||||
})
|
||||
|
||||
// Just verify the post was called, don't check debug output
|
||||
expect(cloudRequestStub).to.be.called
|
||||
})
|
||||
|
||||
it('handles errors in payload construction', () => {
|
||||
const error = new Error('test error')
|
||||
|
||||
sinon.stub(JSON, 'stringify').throws(new Error('JSON error'))
|
||||
|
||||
reportStudioError({
|
||||
cloudApi,
|
||||
studioHash: 'abc123',
|
||||
projectSlug: 'test-project',
|
||||
error,
|
||||
studioMethod: 'testMethod',
|
||||
})
|
||||
|
||||
// Just verify the post was called, don't check debug output
|
||||
expect(cloudRequestStub).to.be.called
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,6 @@ import esbuild from 'esbuild'
|
||||
import type { StudioManager as StudioManagerShape } from '@packages/server/lib/cloud/studio'
|
||||
import os from 'os'
|
||||
|
||||
const pkg = require('@packages/root')
|
||||
|
||||
const { outputFiles: [{ contents: stubStudioRaw }] } = esbuild.buildSync({
|
||||
entryPoints: [path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'studio', 'test-studio.ts')],
|
||||
bundle: true,
|
||||
@@ -18,15 +16,17 @@ const { outputFiles: [{ contents: stubStudioRaw }] } = esbuild.buildSync({
|
||||
const stubStudio = new TextDecoder('utf-8').decode(stubStudioRaw)
|
||||
|
||||
describe('lib/cloud/studio', () => {
|
||||
let stubbedCrossFetch: sinon.SinonStub
|
||||
let studioManager: StudioManagerShape
|
||||
let studio: StudioServerShape
|
||||
let StudioManager: typeof import('@packages/server/lib/cloud/studio').StudioManager
|
||||
let reportStudioError: sinon.SinonStub
|
||||
|
||||
beforeEach(async () => {
|
||||
stubbedCrossFetch = sinon.stub()
|
||||
reportStudioError = sinon.stub()
|
||||
StudioManager = (proxyquire('../lib/cloud/studio', {
|
||||
'cross-fetch': stubbedCrossFetch,
|
||||
'./api/studio/report_studio_error': {
|
||||
reportStudioError,
|
||||
},
|
||||
}) as typeof import('@packages/server/lib/cloud/studio')).StudioManager
|
||||
|
||||
studioManager = new StudioManager()
|
||||
@@ -53,30 +53,12 @@ describe('lib/cloud/studio', () => {
|
||||
const error = new Error('foo')
|
||||
|
||||
sinon.stub(studio, 'initializeRoutes').throws(error)
|
||||
sinon.stub(studio, 'reportError')
|
||||
|
||||
studioManager.initializeRoutes({} as any)
|
||||
|
||||
expect(studioManager.status).to.eq('IN_ERROR')
|
||||
expect(stubbedCrossFetch).to.be.calledWithMatch(sinon.match((url: string) => url.endsWith('/studio/errors')), {
|
||||
agent: sinon.match.any,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': pkg.version,
|
||||
'x-os-name': 'darwin',
|
||||
'x-arch': 'x64',
|
||||
},
|
||||
body: sinon.match((body) => {
|
||||
const parsedBody = JSON.parse(body)
|
||||
|
||||
expect(parsedBody.studioHash).to.eq('abcdefg')
|
||||
expect(parsedBody.errors[0].name).to.eq(error.name)
|
||||
expect(parsedBody.errors[0].stack).to.eq(error.stack)
|
||||
expect(parsedBody.errors[0].message).to.eq(error.message)
|
||||
|
||||
return true
|
||||
}),
|
||||
})
|
||||
expect(studio.reportError).to.be.calledWithMatch(error, 'initializeRoutes', {})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,58 +67,34 @@ describe('lib/cloud/studio', () => {
|
||||
const error = new Error('foo')
|
||||
|
||||
sinon.stub(studio, 'canAccessStudioAI').throws(error)
|
||||
sinon.stub(studio, 'reportError')
|
||||
|
||||
await studioManager.canAccessStudioAI({} as any)
|
||||
|
||||
expect(studioManager.status).to.eq('IN_ERROR')
|
||||
expect(stubbedCrossFetch).to.be.calledWithMatch(sinon.match((url: string) => url.endsWith('/studio/errors')), {
|
||||
agent: sinon.match.any,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': pkg.version,
|
||||
'x-os-name': 'darwin',
|
||||
'x-arch': 'x64',
|
||||
},
|
||||
body: sinon.match((body) => {
|
||||
const parsedBody = JSON.parse(body)
|
||||
|
||||
expect(parsedBody.studioHash).to.eq('abcdefg')
|
||||
expect(parsedBody.errors[0].name).to.eq(error.name)
|
||||
expect(parsedBody.errors[0].stack).to.eq(error.stack)
|
||||
expect(parsedBody.errors[0].message).to.eq(error.message)
|
||||
|
||||
return true
|
||||
}),
|
||||
})
|
||||
expect(studio.reportError).to.be.calledWithMatch(error, 'canAccessStudioAI', {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInErrorManager', () => {
|
||||
it('creates a studio manager in error state', () => {
|
||||
const manager = StudioManager.createInErrorManager(new Error('foo'))
|
||||
const error = new Error('foo')
|
||||
const manager = StudioManager.createInErrorManager({
|
||||
error,
|
||||
cloudApi: {} as any,
|
||||
studioHash: 'abcdefg',
|
||||
projectSlug: '1234',
|
||||
studioMethod: 'initializeRoutes',
|
||||
})
|
||||
|
||||
expect(manager.status).to.eq('IN_ERROR')
|
||||
|
||||
expect(stubbedCrossFetch).to.be.calledWithMatch(sinon.match((url: string) => url.endsWith('/studio/errors')), {
|
||||
agent: sinon.match.any,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-cypress-version': pkg.version,
|
||||
'x-os-name': 'darwin',
|
||||
'x-arch': 'x64',
|
||||
},
|
||||
body: sinon.match((body) => {
|
||||
const parsedBody = JSON.parse(body)
|
||||
|
||||
expect(parsedBody.studioHash).to.be.undefined
|
||||
expect(parsedBody.errors[0].name).to.eq('Error')
|
||||
expect(parsedBody.errors[0].stack).to.be.a('string')
|
||||
expect(parsedBody.errors[0].message).to.eq('foo')
|
||||
|
||||
return true
|
||||
}),
|
||||
expect(reportStudioError).to.be.calledWithMatch({
|
||||
error,
|
||||
cloudApi: {} as any,
|
||||
studioHash: 'abcdefg',
|
||||
projectSlug: '1234',
|
||||
studioMethod: 'initializeRoutes',
|
||||
studioMethodArgs: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ const savedState = require(`../../lib/saved_state`)
|
||||
const runEvents = require(`../../lib/plugins/run_events`)
|
||||
const system = require(`../../lib/util/system`)
|
||||
const { getCtx } = require(`../../lib/makeDataContext`)
|
||||
const studio = require('../../lib/cloud/api/get_and_initialize_studio_manager')
|
||||
const studio = require('../../lib/cloud/api/studio/get_and_initialize_studio_manager')
|
||||
const api = require('../../lib/cloud/api').default
|
||||
const { ProtocolManager } = require('../../lib/cloud/protocol')
|
||||
const browsers = require('../../lib/browsers')
|
||||
|
||||
@@ -25,6 +25,7 @@ type AsyncRetry = <TArgs extends any[], TResult>(
|
||||
) => (...args: TArgs) => Promise<TResult>
|
||||
|
||||
export interface StudioServerOptions {
|
||||
studioHash?: string
|
||||
studioPath: string
|
||||
projectSlug?: string
|
||||
cloudApi: StudioCloudApi
|
||||
@@ -40,6 +41,11 @@ export interface StudioServerShape {
|
||||
canAccessStudioAI(browser: Cypress.Browser): Promise<boolean>
|
||||
addSocketListeners(socket: Socket): void
|
||||
initializeStudioAI(options: StudioAIInitializeOptions): Promise<void>
|
||||
reportError(
|
||||
error: unknown,
|
||||
studioMethod: string,
|
||||
...studioMethodArgs: unknown[]
|
||||
): void
|
||||
destroy(): Promise<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ module.exports = async function (params) {
|
||||
const cloudProtocolFileSource = await getProtocolFileSource(cloudProtocolFilePath)
|
||||
const projectBaseFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/project-base.ts')
|
||||
const projectBaseFileSource = await getStudioFileSource(projectBaseFilePath)
|
||||
const getAndInitializeStudioManagerFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/get_and_initialize_studio_manager.ts')
|
||||
const getAndInitializeStudioManagerFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts')
|
||||
const getAndInitializeStudioManagerFileSource = await getStudioFileSource(getAndInitializeStudioManagerFilePath)
|
||||
|
||||
await Promise.all([
|
||||
|
||||
@@ -2,7 +2,7 @@ process.env.CYPRESS_INTERNAL_ENV = process.env.CYPRESS_INTERNAL_ENV ?? 'producti
|
||||
|
||||
import path from 'path'
|
||||
import fs from 'fs-extra'
|
||||
import { retrieveAndExtractStudioBundle, studioPath } from '@packages/server/lib/cloud/api/get_and_initialize_studio_manager'
|
||||
import { retrieveAndExtractStudioBundle, studioPath } from '@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager'
|
||||
|
||||
export const downloadStudioTypes = async (): Promise<void> => {
|
||||
await retrieveAndExtractStudioBundle({ projectId: 'ypt4pf' })
|
||||
|
||||
Reference in New Issue
Block a user