(raw: unknown, httpStatus?: number, apiUrl?: string)
| 353 | } |
| 354 | |
| 355 | function parseEnvelopeBody(raw: unknown, httpStatus?: number, apiUrl?: string): ErrorEnvelopeBody { |
| 356 | if (typeof raw !== 'object' || raw === null) { |
| 357 | const fallbackCode = codeFromHttpStatus(httpStatus); |
| 358 | return { |
| 359 | code: fallbackCode, |
| 360 | message: |
| 361 | fallbackCode === 'INTERNAL' |
| 362 | ? 'Server returned a malformed error response.' |
| 363 | : `Server returned ${httpStatus} without an error envelope.`, |
| 364 | nextAction: |
| 365 | fallbackCode === 'INTERNAL' |
| 366 | ? 'Server error. Retry once; if it persists, report the `requestId` to support@testsprite.com.' |
| 367 | : '', |
| 368 | requestId: 'unknown', |
| 369 | details: {}, |
| 370 | }; |
| 371 | } |
| 372 | const obj = raw as Record<string, unknown>; |
| 373 | |
| 374 | // Raw Nest/Express 404 shape: `{ message, error: "Not Found", statusCode }`. |
| 375 | // The current parser was matching `obj.error` (a string) and falling to |
| 376 | // `Server error.` while losing the actual `message` from the body |
| 377 | // (e.g. `Cannot POST /api/cli/v1/tests/{id}/runs`). Detect this shape |
| 378 | // explicitly so the user sees something actionable like |
| 379 | // "endpoint not deployed" instead of the generic CLI failure text. |
| 380 | if (typeof obj.error === 'string' && typeof obj.message === 'string') { |
| 381 | const message = obj.message; |
| 382 | const cannotMethod = /^Cannot (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) /.exec(message); |
| 383 | const isRouteMissing404 = (httpStatus === 404 || obj.statusCode === 404) && !!cannotMethod; |
| 384 | return { |
| 385 | code: codeFromHttpStatus(httpStatus), |
| 386 | message: isRouteMissing404 |
| 387 | ? `${message} — endpoint not available on the current backend deployment.` |
| 388 | : message, |
| 389 | nextAction: isRouteMissing404 |
| 390 | ? 'Verify the CLI is targeting the right environment (check `testsprite auth status` for `env`), and confirm the backend has the corresponding M3.3 piece deployed.' |
| 391 | : '', |
| 392 | requestId: 'unknown', |
| 393 | details: { statusCode: obj.statusCode }, |
| 394 | }; |
| 395 | } |
| 396 | |
| 397 | const errorObj = (obj.error ?? obj) as Record<string, unknown>; |
| 398 | const rawCode = isErrorCode(errorObj.code) ? errorObj.code : codeFromHttpStatus(httpStatus); |
| 399 | const message = typeof errorObj.message === 'string' ? errorObj.message : 'Server error.'; |
| 400 | const details = |
| 401 | typeof errorObj.details === 'object' && errorObj.details !== null |
| 402 | ? (errorObj.details as Record<string, unknown>) |
| 403 | : {}; |
| 404 | const nextAction = typeof errorObj.nextAction === 'string' ? errorObj.nextAction : ''; |
| 405 | const requestId = typeof errorObj.requestId === 'string' ? errorObj.requestId : 'unknown'; |
| 406 | |
| 407 | // Client-side re-map: RATE_LIMITED with credits signal -> INSUFFICIENT_CREDITS. |
| 408 | // This makes the code non-retriable and sets exit 12 before the retry |
| 409 | // decision in http.ts sees it, so BOTH retry and exit-code paths are correct. |
| 410 | // A nextAction pointing to the billing page is synthesized when the backend |
| 411 | // didn't supply one (older backend versions). |
| 412 | if (isInsufficientCredits(rawCode, message, details)) { |
no test coverage detected