From 38f980e0c4491b09b19c1663f0d7fa98e50acd81 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 23 Apr 2025 15:54:30 -0500 Subject: [PATCH] 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 --- .../get_and_initialize_studio_manager.ts | 40 ++-- .../cloud/api/studio/report_studio_error.ts | 101 ++++++++ packages/server/lib/cloud/exception.ts | 13 +- packages/server/lib/cloud/strip_path.ts | 14 ++ packages/server/lib/cloud/studio.ts | 56 ++--- packages/server/lib/project-base.ts | 2 +- .../fixtures/cloud/studio/test-studio.ts | 6 +- .../get_and_initialize_studio_manager_spec.ts | 56 +++-- .../api/studio/report_studio_error_spec.ts | 215 ++++++++++++++++++ .../server/test/unit/cloud/studio_spec.ts | 90 ++------ packages/server/test/unit/project_spec.js | 2 +- .../types/src/studio/studio-server-types.ts | 6 + scripts/after-pack-hook.js | 2 +- scripts/gulp/tasks/gulpCloudDeliveredTypes.ts | 2 +- 14 files changed, 457 insertions(+), 148 deletions(-) rename packages/server/lib/cloud/api/{ => studio}/get_and_initialize_studio_manager.ts (79%) create mode 100644 packages/server/lib/cloud/api/studio/report_studio_error.ts create mode 100644 packages/server/lib/cloud/strip_path.ts rename packages/server/test/unit/cloud/api/{ => studio}/get_and_initialize_studio_manager_spec.ts (86%) create mode 100644 packages/server/test/unit/cloud/api/studio/report_studio_error_spec.ts diff --git a/packages/server/lib/cloud/api/get_and_initialize_studio_manager.ts b/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts similarity index 79% rename from packages/server/lib/cloud/api/get_and_initialize_studio_manager.ts rename to packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts index 7ece89b6b2..76432e9113 100644 --- a/packages/server/lib/cloud/api/get_and_initialize_studio_manager.ts +++ b/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts @@ -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 => { 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 }) } diff --git a/packages/server/lib/cloud/api/studio/report_studio_error.ts b/packages/server/lib/cloud/api/studio/report_studio_error.ts new file mode 100644 index 0000000000..15e3c23072 --- /dev/null +++ b/packages/server/lib/cloud/api/studio/report_studio_error.ts @@ -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, + ) + } +} diff --git a/packages/server/lib/cloud/exception.ts b/packages/server/lib/cloud/exception.ts index dfdcdc4c04..416c8a5c29 100644 --- a/packages/server/lib/cloud/exception.ts +++ b/packages/server/lib/cloud/exception.ts @@ -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 `${fileName}` - }) -} +const { stripPath } = require('./strip_path') export = { getErr (err: Error) { diff --git a/packages/server/lib/cloud/strip_path.ts b/packages/server/lib/cloud/strip_path.ts new file mode 100644 index 0000000000..c0007b9eaa --- /dev/null +++ b/packages/server/lib/cloud/strip_path.ts @@ -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 `${fileName}` + }) +} diff --git a/packages/server/lib/cloud/studio.ts b/packages/server/lib/cloud/studio.ts index 8b7f1ec954..6ca0cdc08d 100644 --- a/packages/server/lib/cloud/studio.ts +++ b/packages/server/lib/cloud/studio.ts @@ -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(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 { + 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 } } diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 39117b9991..814d5bea9b 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -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' diff --git a/packages/server/test/support/fixtures/cloud/studio/test-studio.ts b/packages/server/test/support/fixtures/cloud/studio/test-studio.ts index db15973f3a..5ef735c746 100644 --- a/packages/server/test/support/fixtures/cloud/studio/test-studio.ts +++ b/packages/server/test/support/fixtures/cloud/studio/test-studio.ts @@ -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 { return Promise.resolve() } - + addSocketListeners (socket: Socket): void { // This is a test implementation that does nothing } diff --git a/packages/server/test/unit/cloud/api/get_and_initialize_studio_manager_spec.ts b/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts similarity index 86% rename from packages/server/test/unit/cloud/api/get_and_initialize_studio_manager_spec.ts rename to packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts index 30a4da88b5..91a69097ea 100644 --- a/packages/server/test/unit/cloud/api/get_and_initialize_studio_manager_spec.ts +++ b/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts @@ -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', + }) }) }) }) diff --git a/packages/server/test/unit/cloud/api/studio/report_studio_error_spec.ts b/packages/server/test/unit/cloud/api/studio/report_studio_error_spec.ts new file mode 100644 index 0000000000..e4c1a595ac --- /dev/null +++ b/packages/server/test/unit/cloud/api/studio/report_studio_error_spec.ts @@ -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('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('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('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 + }) + }) +}) diff --git a/packages/server/test/unit/cloud/studio_spec.ts b/packages/server/test/unit/cloud/studio_spec.ts index 36d51905f4..89b5cae8bf 100644 --- a/packages/server/test/unit/cloud/studio_spec.ts +++ b/packages/server/test/unit/cloud/studio_spec.ts @@ -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, }) }) }) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 147530d20d..f47c126b31 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -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') diff --git a/packages/types/src/studio/studio-server-types.ts b/packages/types/src/studio/studio-server-types.ts index bc935e994f..a626f93175 100644 --- a/packages/types/src/studio/studio-server-types.ts +++ b/packages/types/src/studio/studio-server-types.ts @@ -25,6 +25,7 @@ type AsyncRetry = ( ) => (...args: TArgs) => Promise export interface StudioServerOptions { + studioHash?: string studioPath: string projectSlug?: string cloudApi: StudioCloudApi @@ -40,6 +41,11 @@ export interface StudioServerShape { canAccessStudioAI(browser: Cypress.Browser): Promise addSocketListeners(socket: Socket): void initializeStudioAI(options: StudioAIInitializeOptions): Promise + reportError( + error: unknown, + studioMethod: string, + ...studioMethodArgs: unknown[] + ): void destroy(): Promise } diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index 19102d9266..15bb1ba9d0 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -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([ diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index 63c663ead5..cddde7947c 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -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 => { await retrieveAndExtractStudioBundle({ projectId: 'ypt4pf' })