diff --git a/packages/server/.gitignore b/packages/server/.gitignore new file mode 100644 index 00000000000..e691b590355 --- /dev/null +++ b/packages/server/.gitignore @@ -0,0 +1 @@ +lib/validations diff --git a/packages/server/lib/cloud/api/create_instance.ts b/packages/server/lib/cloud/api/create_instance.ts index 4ef74f73d3e..f1ea5f965ee 100644 --- a/packages/server/lib/cloud/api/create_instance.ts +++ b/packages/server/lib/cloud/api/create_instance.ts @@ -3,29 +3,13 @@ import { asyncRetry, exponentialBackoff } from '../../util/async_retry' import * as errors from '../../errors' import { isAxiosError } from 'axios' -const MAX_RETRIES = 3 - -export interface CreateInstanceResponse { - spec: string | null - instanceId: string | null - claimedInstances: number - estimatedWallClockDuration: number | null - totalInstances: number -} +// Import cloud validation types for better type safety +import type { + PostRunInstanceRequest_v2Type as CreateInstanceRequestBody, + PostRunInstanceResponse_v2 as CreateInstanceResponse, +} from '../../validations/cloudValidations' -export interface CreateInstanceRequestBody { - spec: string | null - groupId: string - machineId: string - platform: { - browserName: string - browserVersion: string - osCpus: any[] - osMemory: Record | null - osName: string - osVersion: string - } -} +const MAX_RETRIES = 3 export const createInstance = async (runId: string, instanceData: CreateInstanceRequestBody, timeout: number = 0): Promise => { let attemptNumber = 0 diff --git a/packages/server/lib/cloud/api/index.ts b/packages/server/lib/cloud/api/index.ts index 16c775cd324..4c6566a6f0d 100644 --- a/packages/server/lib/cloud/api/index.ts +++ b/packages/server/lib/cloud/api/index.ts @@ -30,15 +30,26 @@ import type { ProjectBase } from '../../project-base' import { PUBLIC_KEY_VERSION } from '../constants' -// axios implementation disabled until proxy issues can be diagnosed/fixed -// TODO: https://github.com/cypress-io/cypress/issues/31490 -//import { createInstance } from './create_instance' -import type { CreateInstanceRequestBody, CreateInstanceResponse } from './create_instance' - import { transformError } from './axios_middleware/transform_error' import { DecryptionError } from './cloud_request_errors' import { isNonRetriableCertErrorCode } from '../network/non_retriable_cert_error_codes' +// Import cloud validation types for better type safety +import type { + PostRunRequest_v3Type as CreateRunRequestType, + PostRunResponse_v3Type as CreateRunResponseType, + PostRunInstanceRequest_v2Type as CreateInstanceRequestType, + PostRunInstanceResponse_v2 as CreateInstanceResponse, + PostInstanceResultsRequest_v1Type as PostInstanceResultsRequestType, + PostInstanceResultsResponse_v1Type as PostInstanceResultsResponseType, + PostInstanceTestsResponse_v1Type as PostInstanceTestsResponseType, + PutInstanceResponse_v2Type as UpdateInstanceStdoutResponseType, + PutInstanceStdoutRequest_v1Type as UpdateInstanceStdoutRequestType, +} from '../../validations/cloudValidations' + +// Define response type for putInstanceArtifacts (returns z.ZodAny with resExample: {}) +type PutInstanceArtifactsResponseType = any + const THIRTY_SECONDS = humanInterval('30 seconds') const SIXTY_SECONDS = humanInterval('60 seconds') const TWO_MINUTES = humanInterval('2 minutes') @@ -249,47 +260,15 @@ function noProxyPreflightTimeout (): number { } } -export type CreateRunOptions = { +// Use cloud validation types for better type safety +export type CreateRunOptions = CreateRunRequestType & { projectRoot: string - ci: { - params: string - provider: string - } - ciBuildId: string - projectId: string - recordKey: string - commit: string - specs: string[] - group: string - platform: string - parallel: boolean - specPattern: string[] - tags: string[] - testingType: 'e2e' | 'component' - timeout?: number project: ProjectBase - autoCancelAfterFailures?: number | undefined + timeout?: number } -type CreateRunResponse = { - groupId: string - machineId: string - runId: string - tags: string[] | null - runUrl: string - warnings: (Record & { - code: string - message: string - name: string - })[] - captureProtocolUrl?: string | undefined - capture?: { - url?: string - tags: string[] | null - mountVersion?: number - disabledMessage?: string - } | undefined -} +// Use cloud validation types for better type safety +type CreateRunResponse = CreateRunResponseType export type ArtifactMetadata = { url: string @@ -348,26 +327,26 @@ export default { rp, // For internal testing - setPreflightResult (toSet) { + setPreflightResult (toSet: any): void { preflightResult = { ...preflightResult, ...toSet, } }, - resetPreflightResult () { + resetPreflightResult (): void { recordRoutes = apiRoutes preflightResult = { encrypt: true, } }, - ping () { + ping (): Bluebird { return rp.get(apiRoutes.ping()) .catch(tagError) }, - getAuthUrls () { + getAuthUrls (): Bluebird { return rp.get({ url: apiRoutes.auth(), json: true, @@ -453,7 +432,7 @@ export default { } } - if (script) { + if (script && (options.testingType === 'e2e' || options.testingType === 'component')) { const config = options.project.getConfig() await options.project.protocolManager.prepareAndSetupProtocol(script, { @@ -478,7 +457,7 @@ export default { .catch(tagError) }, - createInstance (runId: string, body: CreateInstanceRequestBody, timeout?: number): Bluebird { + createInstance (runId: string, body: CreateInstanceRequestType, timeout?: number): Bluebird { return retryWithBackoff((attemptIndex) => { return rp.post({ body, @@ -497,7 +476,7 @@ export default { }) as Bluebird }, - postInstanceTests (options) { + postInstanceTests (options: { instanceId: string, runId: string, timeout?: number, [key: string]: any }): Bluebird { const { instanceId, runId, timeout, ...body } = options return retryWithBackoff((attemptIndex) => { @@ -518,7 +497,7 @@ export default { }) }, - updateInstanceStdout (options) { + updateInstanceStdout (options: UpdateInstanceStdoutRequestType & { instanceId: string, runId: string, timeout?: number }): Bluebird { return retryWithBackoff((attemptIndex) => { return rp.put({ url: recordRoutes.instanceStdout(options.instanceId), @@ -538,7 +517,7 @@ export default { }) }, - updateInstanceArtifacts (options: UpdateInstanceArtifactsOptions, body: UpdateInstanceArtifactsPayload) { + updateInstanceArtifacts (options: UpdateInstanceArtifactsOptions, body: UpdateInstanceArtifactsPayload): Bluebird { debug('PUT %s %o', recordRoutes.instanceArtifacts(options.instanceId), body) return retryWithBackoff((attemptIndex) => { @@ -558,7 +537,7 @@ export default { }) }, - postInstanceResults (options) { + postInstanceResults (options: PostInstanceResultsRequestType & { instanceId: string, runId: string, timeout?: number }): Bluebird { return retryWithBackoff((attemptIndex) => { return rp.post({ url: recordRoutes.instanceResults(options.instanceId), @@ -585,7 +564,7 @@ export default { }) }, - createCrashReport (body, authToken, timeout = 3000) { + createCrashReport (body: any, authToken: string, timeout = 3000): Bluebird { return rp.post({ url: apiRoutes.exceptions(), json: true, @@ -598,7 +577,7 @@ export default { .catch(tagError) }, - postLogout (authToken) { + postLogout (authToken: string): Bluebird { return Bluebird.join( this.getAuthUrls(), machineId.machineId(), @@ -619,11 +598,11 @@ export default { ) }, - clearCache () { + clearCache (): void { responseCache = {} }, - sendPreflight (preflightInfo) { + sendPreflight (preflightInfo: any): Bluebird { return retryWithBackoff(async (attemptIndex) => { const { projectRoot, timeout, ...preflightRequestBody } = preflightInfo @@ -680,7 +659,7 @@ export default { return result }) }, - + async getCaptureProtocolScript (url: string, options: { displayRetryErrors?: boolean } = { displayRetryErrors: true }) { // TODO(protocol): Ensure this is removed in production if (process.env.CYPRESS_LOCAL_PROTOCOL_PATH) { diff --git a/packages/server/lib/modes/record.ts b/packages/server/lib/modes/record.ts index afe990df268..1c922a94e70 100644 --- a/packages/server/lib/modes/record.ts +++ b/packages/server/lib/modes/record.ts @@ -532,7 +532,7 @@ async function createInstance (options: InstanceOptions) { } } -const _postInstanceTests = ({ +async function _postInstanceTests ({ runId, instanceId, config, @@ -541,17 +541,18 @@ const _postInstanceTests = ({ parallel, ciBuildId, group, -}) => { - return api.postInstanceTests({ - runId, - instanceId, - config, - tests, - hooks, - }) - .catch((err: any) => { - throwCloudCannotProceed({ parallel, ciBuildId, group, err }) - }) +}) { + try { + return await api.postInstanceTests({ + runId, + instanceId, + config, + tests, + hooks, + }) + } catch (err: unknown) { + throw cloudCannotProceedErr({ parallel, ciBuildId, group, err }) + } } const createRunAndRecordSpecs = (options: any = {}) => { @@ -778,42 +779,34 @@ const createRunAndRecordSpecs = (options: any = {}) => { }) .value() - const responseDidFail = {} - const response = await _postInstanceTests({ - runId, - instanceId, - config: resolvedRuntimeConfig, - tests, - hooks, - parallel, - ciBuildId, - group, - }) - .catch((err: any) => { - onError(err) - - return responseDidFail - }) - - if (response === responseDidFail) { - debug('`responseDidFail` equals `response`, allowing browser to hang until it is killed: Response %o', { responseDidFail }) + try { + const response = await _postInstanceTests({ + runId, + instanceId, + config: resolvedRuntimeConfig, + tests, + hooks, + parallel, + ciBuildId, + group, + }) - // dont call the cb, let the browser hang until it's killed - return - } + if (_.some(response.actions, { type: 'SPEC', action: 'SKIP' })) { + errorsWarning('CLOUD_CANCEL_SKIPPED_SPEC') - if (_.some(response.actions, { type: 'SPEC', action: 'SKIP' })) { - errorsWarning('CLOUD_CANCEL_SKIPPED_SPEC') + // set a property on the response so the browser runner + // knows not to start executing tests + project.emit('end', { skippedSpec: true, stats: {} }) - // set a property on the response so the browser runner - // knows not to start executing tests - project.emit('end', { skippedSpec: true, stats: {} }) + // dont call the cb, let the browser hang until it's killed + return + } - // dont call the cb, let the browser hang until it's killed - return + return cb(response) + } catch (err: unknown) { + onError(err) + debug('postInstanceTests failed, allowing browser to hang until it is killed: Error %o', { err }) } - - return cb(response) }) return runAllSpecs({ diff --git a/packages/server/package.json b/packages/server/package.json index 7dfadcb0f2a..f178dee4af3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -4,13 +4,13 @@ "private": true, "main": "index.js", "scripts": { - "build-prod": "tsc || echo 'built, with type errors'", - "check-ts": "tsc --noEmit", + "build-prod": "yarn ensure-cloud-validations && tsc || echo 'built, with type errors'", + "check-ts": "yarn ensure-cloud-validations && tsc --noEmit", "clean-deps": "rimraf node_modules", "codecov": "codecov", "dev": "node index.js", "docker": "cd ../.. && WORKING_DIR=/packages/server ./scripts/run-docker-local.sh", - "postinstall": "patch-package", + "postinstall": "patch-package && yarn sync-cloud-validations", "lint": "eslint", "rebuild-better-sqlite3": "electron-rebuild -f -o better-sqlite3", "repl": "node repl.js", @@ -19,7 +19,9 @@ "test-integration": "node ./test/scripts/run.js --glob-in-dir=test/integration", "test-performance": "node ./test/scripts/run.js --glob-in-dir=test/performance", "test-unit": "node ./test/scripts/run.js --glob-in-dir=test/unit", - "test-watch": "./test/scripts/watch test" + "test-watch": "./test/scripts/watch test", + "sync-cloud-validations": "./scripts/sync-cloud-validations.sh sync", + "ensure-cloud-validations": "./scripts/sync-cloud-validations.sh" }, "dependencies": { "@babel/parser": "7.28.0", @@ -211,7 +213,8 @@ "tsconfig-paths": "3.10.1", "webpack": "^5.88.2", "ws": "5.2.4", - "xvfb-maybe": "0.2.1" + "xvfb-maybe": "0.2.1", + "zod": "^3.23.8" }, "files": [ "config", diff --git a/packages/server/scripts/sync-cloud-validations.sh b/packages/server/scripts/sync-cloud-validations.sh new file mode 100755 index 00000000000..cb9e50e4b07 --- /dev/null +++ b/packages/server/scripts/sync-cloud-validations.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# Script to sync cloud validations for the server package +# This ensures we have up-to-date TypeScript definitions for API operations + +set -e + +INTERNAL_CLOUD_ENV=${CYPRESS_INTERNAL_ENV:-production} + +case $INTERNAL_CLOUD_ENV in + test) VALIDATION_BASE="https://api.cypress.io" ;; + production) VALIDATION_BASE="https://api.cypress.io" ;; + staging) VALIDATION_BASE="https://api-staging.cypress.io" ;; + development) VALIDATION_BASE="http://localhost:1234" ;; + *) VALIDATION_BASE="https://api.cypress.io" ;; +esac + +# Output to packages/server/lib/validations +OUTPUT_FOLDER="$(dirname "$0")/../lib/validations" +JS_FILE="$OUTPUT_FOLDER/cloudValidations.js" +DTS_FILE="$OUTPUT_FOLDER/cloudValidations.d.ts" + + +sync_cloud_validations() { + echo "Syncing cloud validations from $VALIDATION_BASE..." + + # Create output directory if it doesn't exist + mkdir -p "$OUTPUT_FOLDER" + + # Download types only (safer than downloading executable .js schemas) + echo "Downloading types..." + curl -s -D /tmp/types_headers "$VALIDATION_BASE/cypress-app/validations/types" > "$DTS_FILE" + + # TODO: Download .js validations when cloud package publishes an npm SDK + # For now, we only download TypeScript definitions for type safety + # echo "Downloading validations..." + # curl -s -D /tmp/validations_headers "$VALIDATION_BASE/cypress-app/validations" > "$JS_FILE" + + # Extract ETag headers + # VALIDATIONS_ETAG=$(grep -i "etag:" /tmp/validations_headers | cut -d' ' -f2 | tr -d '\r\n') + TYPES_ETAG=$(grep -i "etag:" /tmp/types_headers | cut -d' ' -f2 | tr -d '\r\n') + + # Add ETag as comment to the types file + { + echo "// ETag: $TYPES_ETAG" + echo "// Last-Synced: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "" + cat "$DTS_FILE" + } > "$DTS_FILE.tmp" && mv "$DTS_FILE.tmp" "$DTS_FILE" + + # Clean up temp files + rm -f /tmp/types_headers + + echo "✅ Cloud validations synced successfully" +} + +ensure_cloud_validations() { + if [[ ! -f "$DTS_FILE" ]]; then + echo "Cloud validation types file missing, syncing..." + if ! sync_cloud_validations; then + echo "❌ Failed to sync cloud validations. Build may fail without these files." + exit 1 + fi + return + fi + + # Extract stored ETag from the types file + STORED_DTS_ETAG=$(head -n 1 "$DTS_FILE" | sed 's|// ETag: ||' | tr -d '\r\n') + + echo "Checking if cloud validations are up to date..." + + # Get current ETag without downloading the full content + # If we can't fetch ETag (offline), just use existing file + CURRENT_DTS_ETAG=$(curl -s -I "$VALIDATION_BASE/cypress-app/validations/types" 2>/dev/null | grep -i "etag:" | cut -d' ' -f2 | tr -d '\r\n') + + # If we couldn't fetch ETag (offline), use existing file + if [[ -z "$CURRENT_DTS_ETAG" ]]; then + echo "⚠️ Could not check ETag (offline?), using existing file" + return + fi + + # Compare ETags + if [[ "$STORED_DTS_ETAG" != "$CURRENT_DTS_ETAG" ]]; then + echo "Cloud validation types are outdated (ETag changed), syncing..." + if ! sync_cloud_validations; then + echo "⚠️ Failed to sync, but existing file will be used" + fi + else + echo "✅ Cloud validation types are up to date (ETag matches)" + fi +} + +# Run the appropriate function based on command line arguments +case "${1:-}" in + sync) + sync_cloud_validations + ;; + *) + ensure_cloud_validations + ;; +esac