diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index a24671da9..dd12d2bfc 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -190,18 +190,18 @@ Edit an issue alert rule |-------|------|-------------| | `id` | string | Alert rule ID | | `name` | string | Alert rule name | -| `organizationId` | string | | -| `createdBy` | string \| null | | -| `dateCreated` | string | | -| `dateUpdated` | string | | -| `triggers` | object \| null | | -| `actionFilters` | array \| null | | -| `environment` | string \| null | | -| `config` | object | | -| `detectorIds` | array \| null | | -| `enabled` | boolean | | -| `lastTriggered` | string \| null | | -| `owner` | string \| null | | +| `organizationId` | string | ID of the organization the alert belongs to | +| `createdBy` | string \| null | ID of the user who created the alert | +| `dateCreated` | string | When the alert was created (ISO 8601) | +| `dateUpdated` | string | When the alert was last updated (ISO 8601) | +| `triggers` | object \| null | Conditions on which the alert triggers | +| `actionFilters` | array \| null | Filters to run before actions fire, each with the actions to fire | +| `environment` | string \| null | Name of the environment the alert is limited to. Null for all environments | +| `config` | object | Configuration of the alert, such as frequency in minutes | +| `detectorIds` | array \| null | IDs of the monitors connected to the alert | +| `enabled` | boolean | Whether the alert is enabled | +| `lastTriggered` | string \| null | When the alert last fired (ISO 8601) | +| `owner` | string \| null | Team or user that owns the alert, such as user:123 or team:456 | | `org` | string | Organization slug | | `project` | string | Project slug | | `status` | string | Rule status (active, disabled) | @@ -389,21 +389,21 @@ Edit a metric alert rule | Field | Type | Description | |-------|------|-------------| -| `owner` | object \| null | | -| `createdBy` | string \| null | | -| `latestGroup` | object \| null | | -| `description` | string \| null | | +| `owner` | object \| null | Team or user that owns the monitor | +| `createdBy` | string \| null | ID of the user who created the monitor | +| `latestGroup` | object \| null | Latest issue the monitor created | +| `description` | string \| null | Description of the monitor | | `id` | string | Alert rule ID | -| `projectId` | string \| null | | +| `projectId` | string \| null | ID of the project the monitor belongs to | | `name` | string | Alert rule name | -| `type` | string | | -| `workflowIds` | array \| null | | -| `dateCreated` | string | | -| `dateUpdated` | string | | -| `dataSources` | array \| null | | -| `conditionGroup` | object \| null | | -| `config` | object | | -| `enabled` | boolean | | +| `type` | string | Type of the monitor, such as metric_issue or error | +| `workflowIds` | array \| null | IDs of the alerts connected to the monitor | +| `dateCreated` | string | When the monitor was created (ISO 8601) | +| `dateUpdated` | string | When the monitor was last updated (ISO 8601) | +| `dataSources` | array \| null | Data sources the monitor evaluates, such as a query subscription | +| `conditionGroup` | object \| null | Conditions that decide the monitor's status | +| `config` | object | Configuration of the monitor, which depends on its type | +| `enabled` | boolean | Whether the monitor is enabled | | `org` | string | Organization slug | | `status` | string | Rule status (active, disabled) | diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 0fd78ece8..b4134347d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -30,44 +30,44 @@ View details of one or more events | Field | Type | Description | |-------|------|-------------| -| `id` | string | | -| `groupID` | string \| null | | +| `id` | string | ID of the event | +| `groupID` | string \| null | ID of the issue the event belongs to | | `eventID` | string | UUID-format event ID | -| `projectID` | string | | -| `message` | string \| null | | -| `title` | string | | -| `location` | string \| null | | -| `user` | object \| null | | -| `tags` | array | | -| `platform` | string \| null | | -| `dateReceived` | string \| null | | -| `contexts` | object \| null | | -| `size` | number \| null | | -| `entries` | array | | -| `dist` | string \| null | | -| `sdk` | object \| null | | -| `context` | object \| null | | -| `packages` | object | | -| `type` | string | | -| `metadata` | object | | -| `errors` | array | | +| `projectID` | string | ID of the project the event belongs to | +| `message` | string \| null | Event message | +| `title` | string | Event title | +| `location` | string \| null | Source location of the event, such as a file and line | +| `user` | object \| null | User context of the event | +| `tags` | array | Tags attached to the event | +| `platform` | string \| null | Platform of the event, such as python or javascript | +| `dateReceived` | string \| null | When Sentry received the event (ISO 8601) | +| `contexts` | object \| null | Contexts of the event, such as device, os, browser and runtime | +| `size` | number \| null | Size of the event payload in bytes | +| `entries` | array | Event interfaces, such as exception, breadcrumbs and request, each with a type and data | +| `dist` | string \| null | Distribution of the release the event was sent with | +| `sdk` | object \| null | SDK that sent the event | +| `context` | object \| null | Additional data sent with the event in its extra field | +| `packages` | object | Modules loaded when the event occurred, keyed by module name with their versions | +| `type` | string | Event type, such as error, default or transaction | +| `metadata` | object | Event metadata | +| `errors` | array | Errors Sentry found while processing the event | | `occurrence` | object \| null | | -| `_meta` | object | | -| `crashFile` | string \| null | | -| `culprit` | string \| null | | -| `dateCreated` | string | | -| `fingerprints` | array | | -| `groupingConfig` | object | | -| `startTimestamp` | number | | -| `endTimestamp` | number | | -| `measurements` | object \| null | | -| `breakdowns` | object \| null | | -| `release` | object \| null | | -| `userReport` | object \| null | | -| `sdkUpdates` | array | | -| `resolvedWith` | array | | -| `nextEventID` | string \| null | | -| `previousEventID` | string \| null | | +| `_meta` | object | Metadata about changes Sentry made to the event's fields, such as data scrubbing or trimming | +| `crashFile` | string \| null | Crash report attachment of the event, if any | +| `culprit` | string \| null | Function or module that caused the event | +| `dateCreated` | string | When the event occurred (ISO 8601) | +| `fingerprints` | array | Grouping hashes calculated for the event | +| `groupingConfig` | object | Grouping configuration used to group the event | +| `startTimestamp` | number | When the transaction started, as a Unix timestamp in seconds | +| `endTimestamp` | number | When the transaction ended, as a Unix timestamp in seconds | +| `measurements` | object \| null | Measurements recorded for the transaction, such as web vitals, keyed by name | +| `breakdowns` | object \| null | Span operation breakdowns for the transaction, keyed by breakdown name | +| `release` | object \| null | Release the event was sent with. Null when the event has no release | +| `userReport` | object \| null | User feedback submitted for the event. Null when there's none | +| `sdkUpdates` | array | Suggested updates for the SDK that sent the event | +| `resolvedWith` | array | How the event's stack frames were resolved during processing, such as which source map lookup method was used | +| `nextEventID` | string \| null | ID of the next event in the issue. Null for the latest event or when adjacentEvents is collapsed | +| `previousEventID` | string \| null | ID of the previous event in the issue. Null for the first event or when adjacentEvents is collapsed | | `formatted` | object | Event rendered as Markdown, XML, or JSON; only returned for API requests with llmFormat | | `trace` | object \| null | Trace context, or null when unavailable | | `attachments` | array | Event attachments; each includes metadata and an absolute authenticated download URL | diff --git a/packages/cli/src/lib/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index 1169822cd..7933258e5 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -10,6 +10,7 @@ import { createOrganizationProjectDetector, createOrganizationWorkflow, deleteOrganizationDetector, + deleteOrganizationWorkflow, getOrganizationDetector, type GetOrganizationDetectorResponse, getOrganizationWorkflow, @@ -35,7 +36,6 @@ import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { apiRequestToRegion, - apiRequestToRegionNoContent, getOrgSdkConfig, type PaginatedResponse, parseLinkHeader, @@ -513,12 +513,15 @@ export async function deleteIssueAlertRule( orgSlug: string, ruleId: string, ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - await apiRequestToRegionNoContent( - regionUrl, - `/organizations/${orgSlug}/workflows/${encodeURIComponent(ruleId)}/`, - { method: "DELETE" }, - ); + const config = await getOrgSdkConfig(orgSlug); + const result = await deleteOrganizationWorkflow({ + ...config, + path: { + organization_id_or_slug: orgSlug, + workflow_id: Number(ruleId), + }, + }); + unwrapResult(result, `Failed to delete issue alert rule '${ruleId}'`); } /** diff --git a/packages/cli/src/lib/api/chunk-upload.ts b/packages/cli/src/lib/api/chunk-upload.ts index f10a1e80d..1aac07c97 100644 --- a/packages/cli/src/lib/api/chunk-upload.ts +++ b/packages/cli/src/lib/api/chunk-upload.ts @@ -24,23 +24,29 @@ import { constants as zlibConstants, zstdCompress as zstdCompressCb, } from "node:zlib"; +import { + type AssembleOrganizationArtifactBundleData, + type AssembleProjectDebugFilesData, + assembleOrganizationArtifactBundle, + assembleProjectDebugFiles, + getOrganizationChunkUploadOptions, +} from "@sentry/api"; import pLimit from "p-limit"; import { array, - type GenericSchema, type InferOutput, nullish, number, object, optional, picklist, + record, string, } from "valibot"; import { ApiError } from "../errors.js"; import { logger } from "../logger.js"; -import { resolveOrgRegion } from "../region.js"; import { getSdkConfig } from "../sentry-client.js"; -import { apiRequestToRegion } from "./infrastructure.js"; +import { getOrgSdkConfig, unwrapValidatedResult } from "./infrastructure.js"; const gzipAsync = promisify(gzipCb); // zstdCompress exists only on Node.js 22.15+. On older runtimes (npm package @@ -96,6 +102,11 @@ export const AssembleResponseSchema = object({ export type AssembleResponse = InferOutput; +/** Response from the DIF assemble endpoint, keyed by file checksum. */ +const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); + +export type DifAssembleResponse = InferOutput; + // ── Types ─────────────────────────────────────────────────────────── /** Chunk metadata after splitting the ZIP for upload. */ @@ -145,11 +156,17 @@ export type UploadEncoding = (typeof UPLOAD_CODECS)[number]; export async function getChunkUploadOptions( orgSlug: string, ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationChunkUploadOptions({ + ...config, + path: { organization_id_or_slug: orgSlug }, + }); + // The schema also reads `maxWait`, which the generated type lacks. + const data = unwrapValidatedResult( + result, + "Failed to get chunk upload options", + ChunkServerOptionsSchema, `organizations/${orgSlug}/chunk-upload/`, - { schema: ChunkServerOptionsSchema }, ); // Self-hosted Sentry instances may return a chunk-upload URL that embeds @@ -572,36 +589,84 @@ export async function uploadMissingBufferChunks(params: { } /** - * Poll an assemble endpoint until the server reports completion. + * Request DIF assembly for one batch of files. + * + * The server answers with one entry per file checksum in the body. Sending the + * same body again polls the assembly state. + * + * @param org - Organization slug + * @param project - Project slug + * @param body - Files to assemble, keyed by overall checksum + */ +export async function assembleDebugFiles( + org: string, + project: string, + body: NonNullable, +): Promise { + const config = await getOrgSdkConfig(org); + const result = await assembleProjectDebugFiles({ + ...config, + path: { organization_id_or_slug: org, project_id_or_slug: project }, + body, + }); + return unwrapValidatedResult( + result, + "Failed to assemble debug files", + DifAssembleResponseSchema, + `projects/${org}/${project}/files/difs/assemble/`, + ); +} + +/** + * Request assembly of an artifact bundle. Sending the same body again polls + * the assembly state. * - * Re-sends the same assemble body on each poll (the server uses + * @param org - Organization slug + * @param body - Bundle checksum, chunk checksums, projects and release + */ +export async function assembleArtifactBundle( + org: string, + body: NonNullable, +): Promise { + const config = await getOrgSdkConfig(org); + const result = await assembleOrganizationArtifactBundle({ + ...config, + path: { organization_id_or_slug: org }, + body, + }); + return unwrapValidatedResult( + result, + "Failed to assemble artifact bundle", + AssembleResponseSchema, + `organizations/${org}/artifactbundle/assemble/`, + ); +} + +/** + * Poll an assemble request until the server reports completion. + * + * `assemble` re-sends the same assemble body on each poll (the server uses * the checksum to look up existing assembly state). By default returns when * the state is `"ok"` or `"created"`; set `waitForOk` to block until the server * finishes processing (`"ok"` only). Throws on `"error"` or timeout. * - * @param params.regionUrl - Region base URL - * @param params.endpoint - The endpoint path to POST to - * @param params.body - The request body to send on each poll + * @param params.assemble - Sends the assemble request and returns its state + * @param params.endpoint - The assemble endpoint path, for error messages * @param params.entityName - Human-readable name for error messages - * @param params.schema - Valibot schema for the response (defaults to {@link AssembleResponseSchema}) * @param params.waitForOk - Keep polling on `"created"`, returning only on `"ok"` * @param params.deadlineMs - Override the default poll timeout window */ export async function pollAssembly(params: { - regionUrl: string; + assemble: () => Promise; endpoint: string; - body: unknown; entityName: string; - schema?: GenericSchema; waitForOk?: boolean; deadlineMs?: number; }): Promise { const { - regionUrl, + assemble, endpoint, - body, entityName, - schema = AssembleResponseSchema, waitForOk = false, deadlineMs = ASSEMBLE_MAX_WAIT_MS, } = params; @@ -610,15 +675,7 @@ export async function pollAssembly(params: { while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - const { data: pollResult } = await apiRequestToRegion( - regionUrl, - endpoint, - { - method: "POST", - body, - schema, - }, - ); + const pollResult = await assemble(); if (pollResult.state === "ok") { return; diff --git a/packages/cli/src/lib/api/conversations.ts b/packages/cli/src/lib/api/conversations.ts index 88e7139d6..d2504c4e4 100644 --- a/packages/cli/src/lib/api/conversations.ts +++ b/packages/cli/src/lib/api/conversations.ts @@ -4,14 +4,14 @@ * Functions for listing and retrieving agent conversation data from the Sentry * Explore agent conversations endpoints. * - * The `/organizations/{org}/agents/conversations/` endpoints are PRIVATE and not - * yet in `@sentry/api` (getsentry/sentry-api-schema). Call them via - * `apiRequestToRegion` with local Valibot schemas (same pattern as `logs.ts` / - * `traces.ts`). Details response shape is documented on - * `AgentConversationDetailsSchema`. Pagination uses `parseLinkHeader`. Revisit - * once these endpoints land in `@sentry/api`. + * Both endpoints are in `@sentry/api`. The list still goes through + * `apiRequestToRegion` because its generated query has no `sort`. The details + * call uses the generated operation; its spans are untyped records there, so + * the response is still checked with `AgentConversationDetailsSchema`. + * Pagination follows the `Link` header. */ +import { retrieveOrganizationAiConversation } from "@sentry/api"; import { array } from "valibot"; import { @@ -29,10 +29,13 @@ import { resolveOrgRegion } from "../region.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, + getOrgSdkConfig, MAX_PAGINATION_PAGES, type PaginatedResponse, paginate, parseLinkHeader, + unwrapPaginatedResult, + validateResponse, } from "./infrastructure.js"; const log = logger.withTag("api.conversations"); @@ -136,15 +139,8 @@ export async function getConversationSpans( truncated: boolean; title: string | null; }> { - const regionUrl = await resolveOrgRegion(orgSlug); - - const params: Record = { - per_page: String(options.perPage ?? 1000), - statsPeriod: options.statsPeriod ?? "30d", - }; - if (options.project) { - params.project = options.project; - } + const config = await getOrgSdkConfig(orgSlug); + const endpoint = `/organizations/${orgSlug}/agents/conversations/${encodeURIComponent(conversationId)}/`; const spans: AgentConversationSpan[] = []; let stats: ConversationStats | undefined; @@ -152,24 +148,36 @@ export async function getConversationSpans( let cursor: string | undefined; for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - if (cursor) { - params.cursor = cursor; - } - - const { data, headers } = - await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/agents/conversations/${encodeURIComponent(conversationId)}/`, - { params, schema: AgentConversationDetailsSchema }, - ); + const result = await retrieveOrganizationAiConversation({ + ...config, + path: { + organization_id_or_slug: orgSlug, + conversation_id: conversationId, + }, + query: { + per_page: options.perPage ?? 1000, + statsPeriod: options.statsPeriod ?? "30d", + project: options.project ? [options.project] : undefined, + cursor, + }, + }); + const pageResult = unwrapPaginatedResult( + result, + "Failed to get agent conversation", + ); + const data: AgentConversationDetails = validateResponse( + AgentConversationDetailsSchema, + pageResult.data, + endpoint, + pageResult.response?.status ?? 0, + ); if (page === 0) { stats = data.stats; title = data.title; } spans.push(...data.spans); - const parsed = parseLinkHeader(headers.get("link") ?? null); - cursor = parsed.nextCursor; + cursor = pageResult.nextCursor; if (!cursor) { break; } diff --git a/packages/cli/src/lib/api/dart-symbols.ts b/packages/cli/src/lib/api/dart-symbols.ts index 6ca5212e8..52da154b8 100644 --- a/packages/cli/src/lib/api/dart-symbols.ts +++ b/packages/cli/src/lib/api/dart-symbols.ts @@ -11,7 +11,6 @@ * debug file (dSYM/ELF). */ -import { type InferOutput, record, string } from "valibot"; import { ApiError } from "../errors.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; @@ -19,14 +18,14 @@ import { ASSEMBLE_MAX_WAIT_MS, ASSEMBLE_POLL_INTERVAL_MS, type AssembleResponse, - AssembleResponseSchema, + assembleDebugFiles, type ChunkInfo, + type DifAssembleResponse, getChunkUploadOptions, hashBuffer, pickUploadEncoding, uploadMissingBufferChunks, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; const log = logger.withTag("api.dart-symbols"); @@ -52,16 +51,6 @@ export type DartSymbolMapUploadOptions = { mapping: DartSymbolMap; }; -// Schemas - -/** - * DIF assemble response — keyed by overall checksum, each value has - * the same shape as the standard assemble response. - */ -const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); - -type DifAssembleResponse = InferOutput; - // Helpers /** Result of checking a DIF assemble response. */ @@ -152,15 +141,7 @@ export async function uploadDartSymbolMap( }; // Step 4: Request DIF assembly - const { data: firstAssemble } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: DifAssembleResponseSchema, - }, - ); + const firstAssemble = await assembleDebugFiles(org, project, assembleBody); const { allDone, missingChecksums } = checkAssembleResponse( firstAssemble, @@ -187,15 +168,7 @@ export async function uploadDartSymbolMap( while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - const { data: pollResult } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: DifAssembleResponseSchema, - }, - ); + const pollResult = await assembleDebugFiles(org, project, assembleBody); const { allDone: done } = checkAssembleResponse( pollResult, diff --git a/packages/cli/src/lib/api/debug-files.ts b/packages/cli/src/lib/api/debug-files.ts index c40e5387a..02d212bb1 100644 --- a/packages/cli/src/lib/api/debug-files.ts +++ b/packages/cli/src/lib/api/debug-files.ts @@ -18,7 +18,6 @@ * collecting `error` details so the caller can report and exit non-zero. */ -import { type InferOutput, record, string } from "valibot"; import { ApiError, ValidationError } from "../errors.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; @@ -26,18 +25,18 @@ import { ASSEMBLE_MAX_WAIT_MS, ASSEMBLE_POLL_INTERVAL_MS, type AssembleResponse, - AssembleResponseSchema, + assembleDebugFiles, type ChunkInfo, type ChunkPayload, type ChunkServerOptions, DEFAULT_MAX_DIF_SIZE, + type DifAssembleResponse, getChunkUploadOptions, hashBuffer, pickUploadEncoding, type UploadEncoding, uploadChunkPayloads, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; const log = logger.withTag("api.debug-files"); @@ -105,16 +104,6 @@ export type DebugFileUploadResult = { detail: string | null; }; -// ── Schemas ───────────────────────────────────────────────────────── - -/** - * DIF assemble response — keyed by overall checksum, each value has the same - * shape as the standard assemble response. - */ -const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); - -type DifAssembleResponse = InferOutput; - // ── Internal types ────────────────────────────────────────────────── /** @@ -315,22 +304,13 @@ async function uploadMissing(params: { /** POST one assemble request per batch and merge the keyed responses. */ async function postAssemble( - regionUrl: string, - endpoint: string, - bodies: readonly unknown[], + org: string, + project: string, + bodies: readonly Record[], ): Promise { const merged: DifAssembleResponse = {}; for (const body of bodies) { - const { data } = await apiRequestToRegion( - regionUrl, - endpoint, - { - method: "POST", - body, - schema: DifAssembleResponseSchema, - }, - ); - Object.assign(merged, data); + Object.assign(merged, await assembleDebugFiles(org, project, body)); } return merged; } @@ -503,7 +483,7 @@ export async function uploadDebugFiles( const effectiveMaxWaitMs = wait ? clampMaxWait(maxWaitMs, serverOptions.maxWait ?? 0) : maxWaitMs; - let response = await postAssemble(regionUrl, endpoint, bodies); + let response = await postAssemble(org, project, bodies); let evaluation = evaluateAssembly(response, chunked, wait); if (evaluation.missingChecksums.size > 0) { await uploadMissing({ @@ -515,7 +495,7 @@ export async function uploadDebugFiles( }); // The server only starts assembling once it is asked again with the // chunks in place, so re-assemble right after delivering them. - response = await postAssemble(regionUrl, endpoint, bodies); + response = await postAssemble(org, project, bodies); evaluation = evaluateAssembly(response, chunked, wait); } @@ -541,7 +521,7 @@ export async function uploadDebugFiles( } await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - response = await postAssemble(regionUrl, endpoint, bodies); + response = await postAssemble(org, project, bodies); evaluation = evaluateAssembly(response, chunked, wait); await uploadMissing({ chunked, diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index cdf9c3793..610bcdf1c 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -806,35 +806,77 @@ export async function apiRequestToRegion( } if (schema) { - const result = safeParse(schema, data); - if (!result.success) { - // Attach structured validation issues to the Sentry event so we can - // diagnose exactly which field(s) failed validation — the ApiError.detail - // string alone may not be visible in the Sentry issue overview. - // Strip valibot issues to metadata only (path/type/message) before - // attaching — full issues embed the raw failing `input` value. - Sentry.setContext("schema_validation", { - endpoint, - status: response.status, - issues: result.issues.slice(0, 10).map((i) => ({ - // Strip raw input from path items — only keep structural path keys. - path: i.path?.map((p) => ({ key: p.key, type: p.type })), - type: i.type, - message: i.message, - })), - }); - throw new ApiError( - `Unexpected response format from ${endpoint}`, - response.status, - result.issues.map((issue) => issue.message).join(", "), - ); - } - return { data: result.output, headers: response.headers }; + return { + data: validateResponse(schema, data, endpoint, response.status), + headers: response.headers, + }; } return { data: data as T, headers: response.headers }; } +/** + * Validate a response body against the CLI's schema for it. + * + * Used for raw requests and for `@sentry/api` results whose generated type is + * looser than what the CLI reads (e.g. untyped span records). + * + * @param schema - Valibot schema for the response body + * @param data - Parsed response body + * @param endpoint - API path, named in the error message + * @param status - HTTP status of the response + * @throws {ApiError} When the body does not match the schema + */ +export function validateResponse( + schema: GenericSchema, + data: unknown, + endpoint: string, + status: number, +): T { + const result = safeParse(schema, data); + if (!result.success) { + // Attach structured validation issues to the Sentry event so we can + // diagnose exactly which field(s) failed validation — the ApiError.detail + // string alone may not be visible in the Sentry issue overview. + // Strip valibot issues to metadata only (path/type/message) before + // attaching — full issues embed the raw failing `input` value. + Sentry.setContext("schema_validation", { + endpoint, + status, + issues: result.issues.slice(0, 10).map((i) => ({ + // Strip raw input from path items — only keep structural path keys. + path: i.path?.map((p) => ({ key: p.key, type: p.type })), + type: i.type, + message: i.message, + })), + }); + throw new ApiError( + `Unexpected response format from ${endpoint}`, + status, + result.issues.map((issue) => issue.message).join(", "), + ); + } + return result.output; +} + +/** + * Unwrap an @sentry/api SDK result and validate its body with the CLI's schema. + * + * @param result - The result from an SDK function call + * @param context - Human-readable context for HTTP error messages + * @param schema - Valibot schema for the response body + * @param endpoint - API path, named in schema-mismatch errors + */ +export function unwrapValidatedResult( + result: Pick, + context: string, + schema: GenericSchema, + endpoint: string, +): T { + const data = unwrapResult(result, context); + return validateResponse(schema, data, endpoint, result.response?.status ?? 0); +} + /** * Extract error detail from a failed HTTP response, attach diagnostic * headers to the Sentry scope, and throw an enriched {@link ApiError}. diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index 654744594..67be8f909 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -5,7 +5,10 @@ */ import type { ListOrganizationIssuesData } from "@sentry/api"; -import { listOrganizationIssues } from "@sentry/api"; +import { + listOrganizationIssues, + resolveOrganizationShortId, +} from "@sentry/api"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; @@ -29,6 +32,7 @@ import { type PaginatedResponse, paginate, unwrapPaginatedResult, + unwrapResult, } from "./infrastructure.js"; const TRAILING_SLASH_RE = /\/$/; @@ -299,9 +303,9 @@ export function getIssue( * Uses the org-scoped endpoint with region-aware routing. * Preferred over {@link getIssue} when the org slug is available. * - * Uses raw `apiRequestToRegion` instead of the SDK's `retrieveAnIssue` - * because the SDK types declare `query?: never`, blocking `collapse` - * and other query parameters. See: https://github.com/getsentry/sentry-api-schema/issues/63 + * Uses raw `apiRequestToRegion` instead of the SDK's `getOrganizationIssue` + * because its `collapse` type allows only `release`, `stats` and `tags`, not + * the `lifetime`, `filtered` and `unhandled` values the CLI sends. * * @param orgSlug - Organization slug (used for region routing) * @param issueId - Numeric issue ID @@ -328,10 +332,6 @@ export async function getIssueInOrg( * Requires organization context to resolve the short ID. * Uses region-aware routing for multi-region support. * - * Uses raw `apiRequestToRegion` instead of the SDK's `resolveAShortId` - * because the SDK types declare `query?: never`, blocking `collapse` - * and other query parameters. See: https://github.com/getsentry/sentry-api-schema/ - * * @param orgSlug - Organization slug * @param shortId - Short ID (e.g., "CLI-G5", "SPOTLIGHT-ELECTRON-4D") * @param options - Optional collapse fields to skip expensive backend queries @@ -342,18 +342,19 @@ export async function getIssueByShortId( options?: { collapse?: IssueCollapseField[] }, ): Promise { const normalizedShortId = shortId.toUpperCase(); - const regionUrl = await resolveOrgRegion(orgSlug); + const config = await getOrgSdkConfig(orgSlug); let data: { group?: SentryIssue }; try { - const result = await apiRequestToRegion<{ group?: SentryIssue }>( - regionUrl, - `/organizations/${orgSlug}/shortids/${normalizedShortId}/`, - { - params: options?.collapse ? { collapse: options.collapse } : undefined, - }, + const result = await resolveOrganizationShortId({ + ...config, + path: { organization_id_or_slug: orgSlug, issue_id: normalizedShortId }, + query: options?.collapse ? { collapse: options.collapse } : undefined, + }); + data = unwrapResult<{ group?: SentryIssue }>( + result, + "Failed to resolve short ID", ); - data = result.data; } catch (error) { // Enrich 404 errors with actionable context. The generic // "Failed to resolve short ID: 404 Not Found" is the most common diff --git a/packages/cli/src/lib/api/preprod-artifacts.ts b/packages/cli/src/lib/api/preprod-artifacts.ts index c96176c97..f39ca3356 100644 --- a/packages/cli/src/lib/api/preprod-artifacts.ts +++ b/packages/cli/src/lib/api/preprod-artifacts.ts @@ -15,6 +15,10 @@ import { createWriteStream } from "node:fs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { + getOrganizationPreprodArtifactInstallDetails, + getOrganizationPreprodArtifactSnapshotLatestBase, +} from "@sentry/api"; import { array, boolean, @@ -46,6 +50,8 @@ import { import { apiRequestToRegion, apiRequestToRegionNoContent, + getOrgSdkConfig, + unwrapValidatedResult, } from "./infrastructure.js"; const log = logger.withTag("api.preprod-artifacts"); @@ -78,14 +84,19 @@ export async function getBuildInstallDetails( org: string, buildId: string, ): Promise { - const regionUrl = await resolveOrgRegion(org); - const endpoint = `organizations/${org}/preprodartifacts/${encodeURIComponent( - buildId, - )}/install-details/`; - const { data } = await apiRequestToRegion(regionUrl, endpoint, { - schema: BuildInstallDetailsSchema, + const config = await getOrgSdkConfig(org); + const result = await getOrganizationPreprodArtifactInstallDetails({ + ...config, + path: { organization_id_or_slug: org, artifact_id: buildId }, }); - return data; + return unwrapValidatedResult( + result, + "Failed to get build install details", + BuildInstallDetailsSchema, + `organizations/${org}/preprodartifacts/${encodeURIComponent( + buildId, + )}/install-details/`, + ); } /** @@ -380,15 +391,19 @@ export async function getLatestBaseSnapshot( appId: string, opts: { branch?: string; project?: string } = {}, ): Promise { - const regionUrl = await resolveOrgRegion(org); + const config = await getOrgSdkConfig(org); try { - const { data } = await apiRequestToRegion( - regionUrl, + const result = await getOrganizationPreprodArtifactSnapshotLatestBase({ + ...config, + path: { organization_id_or_slug: org }, + query: { app_id: appId, branch: opts.branch, project: opts.project }, + }); + // The generated type has no null body; the CLI treats one as "no base". + const data = unwrapValidatedResult( + result, + "Failed to get latest base snapshot", + nullable(LatestBaseSnapshotSchema), `organizations/${org}/preprodartifacts/snapshots/latest-base/`, - { - params: { app_id: appId, branch: opts.branch, project: opts.project }, - schema: nullable(LatestBaseSnapshotSchema), - }, ); return data ? { headArtifactId: data.head_artifact_id, imageCount: data.image_count } diff --git a/packages/cli/src/lib/api/proguard.ts b/packages/cli/src/lib/api/proguard.ts index 5f2147488..84f8ce8cf 100644 --- a/packages/cli/src/lib/api/proguard.ts +++ b/packages/cli/src/lib/api/proguard.ts @@ -12,7 +12,6 @@ * - Multiple files are sent in a single assemble request */ -import { type InferOutput, record, string } from "valibot"; import { ApiError } from "../errors.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; @@ -20,14 +19,14 @@ import { ASSEMBLE_MAX_WAIT_MS, ASSEMBLE_POLL_INTERVAL_MS, type AssembleResponse, - AssembleResponseSchema, + assembleDebugFiles, type ChunkInfo, + type DifAssembleResponse, getChunkUploadOptions, hashBuffer, pickUploadEncoding, uploadMissingBufferChunks, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; const log = logger.withTag("api.proguard"); @@ -53,16 +52,6 @@ export type ProguardUploadOptions = { mappings: ProguardMapping[]; }; -// ── Schemas ───────────────────────────────────────────────────────── - -/** - * DIF assemble response — keyed by overall checksum, each value has - * the same shape as the standard assemble response. - */ -const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); - -type DifAssembleResponse = InferOutput; - // ── Internal types ────────────────────────────────────────────────── /** Per-mapping chunk metadata computed before the assemble request. */ @@ -168,15 +157,7 @@ export async function uploadProguardMappings( } // Step 4: Request DIF assembly — server reports which chunks it needs - const { data: firstAssemble } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: DifAssembleResponseSchema, - }, - ); + const firstAssemble = await assembleDebugFiles(org, project, assembleBody); const checksums = chunkedMappings.map((cm) => cm.overallChecksum); const { allDone, missingChecksums } = checkAssembleResponse( @@ -203,9 +184,8 @@ export async function uploadProguardMappings( // Step 6: Poll assemble endpoint until all mappings are done await pollDifAssembly({ - regionUrl, + assemble: () => assembleDebugFiles(org, project, assembleBody), endpoint: assembleEndpoint, - body: assembleBody, checksums, }); } @@ -217,26 +197,17 @@ export async function uploadProguardMappings( * the state of every checksum entry until all are `"ok"` or `"created"`. */ async function pollDifAssembly(params: { - regionUrl: string; + assemble: () => Promise; endpoint: string; - body: unknown; checksums: string[]; }): Promise { - const { regionUrl, endpoint, body, checksums } = params; + const { assemble, endpoint, checksums } = params; const deadline = Date.now() + ASSEMBLE_MAX_WAIT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - const { data: pollResult } = await apiRequestToRegion( - regionUrl, - endpoint, - { - method: "POST", - body, - schema: DifAssembleResponseSchema, - }, - ); + const pollResult = await assemble(); const { allDone } = checkAssembleResponse(pollResult, checksums, endpoint); if (allDone) { diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index 649e210cf..6ae8c3bc0 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -741,24 +741,24 @@ export async function setCommitsWithRefs( version: string, refs: CommitRef[], ): Promise { - const encodedOrg = encodeApiPathSegment(orgSlug); - if (encodedOrg === null) { + // The SDK percent-encodes path values but lets `.` and `..` through, which + // would collapse the path; reject them here. + if (encodeApiPathSegment(orgSlug) === null) { throw new ValidationError("Invalid organization slug", "organization"); } - const encodedVersion = encodeApiPathSegment(version); - if (encodedVersion === null) { + if (encodeApiPathSegment(version) === null) { throw new ValidationError("Invalid release version", "version"); } - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, - `organizations/${encodedOrg}/releases/${encodedVersion}/`, - { - method: "PUT", - body: { refs }, - }, + const config = await getOrgSdkConfig(orgSlug); + const result = await updateOrganizationRelease({ + ...config, + path: { organization_id_or_slug: orgSlug, version }, + body: { refs }, + }); + return unwrapResult( + result, + `Failed to set commits on release '${version}'`, ); - return data; } /** diff --git a/packages/cli/src/lib/api/replays.ts b/packages/cli/src/lib/api/replays.ts index f1a73234b..7a0cb27df 100644 --- a/packages/cli/src/lib/api/replays.ts +++ b/packages/cli/src/lib/api/replays.ts @@ -5,6 +5,7 @@ */ import { + getOrganizationReplay, type ListProjectReplayRecordingSegmentsResponse, listProjectReplayRecordingSegments, } from "@sentry/api"; @@ -35,6 +36,7 @@ import { paginate, parseLinkHeader, unwrapPaginatedResult, + unwrapValidatedResult, } from "./infrastructure.js"; const log = logger.withTag("api.replays"); @@ -253,16 +255,19 @@ export async function getReplay( orgSlug: string, replayId: string, ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationReplay({ + ...config, + path: { organization_id_or_slug: orgSlug, replay_id: replayId }, + }); + const data = unwrapValidatedResult( + result, + "Failed to get replay", + ReplayDetailsResponseSchema as GenericSchema< + unknown, + ReplayDetailsResponse + >, `/organizations/${orgSlug}/replays/${replayId}/`, - { - schema: ReplayDetailsResponseSchema as GenericSchema< - unknown, - ReplayDetailsResponse - >, - }, ); return normalizeReplayProjectId(data.data); } diff --git a/packages/cli/src/lib/api/sourcemaps.ts b/packages/cli/src/lib/api/sourcemaps.ts index 67425a1ad..3cbf7f9f7 100644 --- a/packages/cli/src/lib/api/sourcemaps.ts +++ b/packages/cli/src/lib/api/sourcemaps.ts @@ -23,8 +23,7 @@ import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { type ZipCompression, ZipWriter } from "../sourcemap/zip.js"; import { - type AssembleResponse, - AssembleResponseSchema, + assembleArtifactBundle, type ChunkInfo, type ChunkServerOptions, getChunkUploadOptions, @@ -34,7 +33,6 @@ import { type UploadEncoding, uploadMissingChunks, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; // ── Re-exports for backward compatibility ─────────────────────────── // These were originally defined in this module. External consumers @@ -333,15 +331,7 @@ async function uploadArtifactBundle(opts: { }; const assembleEndpoint = `organizations/${org}/artifactbundle/assemble/`; - const { data: firstAssemble } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: AssembleResponseSchema, - }, - ); + const firstAssemble = await assembleArtifactBundle(org, assembleBody); // Fail fast on server-side assembly error if (firstAssemble.state === "error") { @@ -377,9 +367,8 @@ async function uploadArtifactBundle(opts: { // Step 6: Poll assemble endpoint. With --wait, block until fully processed // ("ok"); otherwise return as soon as the chunks are accepted ("created"). await pollAssembly({ - regionUrl, + assemble: () => assembleArtifactBundle(org, assembleBody), endpoint: assembleEndpoint, - body: assembleBody, entityName: "Artifact bundle", waitForOk: wait, deadlineMs: maxWaitMs, diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 5cb23d332..559a300aa 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -5,10 +5,12 @@ */ import { + getOrganizationTraceMeta, type ListOrganizationTraceItemAttributesData, type ListOrganizationTraceItemAttributesQueryAttributeType, type ListOrganizationTraceItemAttributesQueryDataset, type ListOrganizationTraceItemAttributesResponse, + listOrganizationEvents, listOrganizationTraceItemAttributes, } from "@sentry/api"; import pLimit from "p-limit"; @@ -40,8 +42,9 @@ import { getOrgSdkConfig, type PaginatedResponse, paginate, - parseLinkHeader, unwrapPaginatedResult, + unwrapValidatedResult, + validateResponse, } from "./infrastructure.js"; const log = logger.withTag("api.traces"); @@ -383,17 +386,18 @@ export async function getTraceMeta( traceId: string, statsPeriod = "14d", ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - - const { data } = await apiRequestToRegion( - regionUrl, + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationTraceMeta({ + ...config, + path: { organization_id_or_slug: orgSlug, trace_id: traceId }, + query: { statsPeriod }, + }); + return unwrapValidatedResult( + result, + "Failed to get trace metadata", + TraceMetaSchema, `/organizations/${orgSlug}/trace-meta/${traceId}/`, - { - params: { statsPeriod }, - schema: TraceMetaSchema, - }, ); - return data; } // --------------------------------------------------------------------------- @@ -565,7 +569,6 @@ type ListTransactionsOptions = { * multi-page (auto-paginating) fetches. */ async function fetchTransactionsPage( - regionUrl: string, orgSlug: string, projectSlug: string, options: ListTransactionsOptions, @@ -583,38 +586,40 @@ async function fetchTransactionsPage( .filter(Boolean) .join(" "); - const { data: response, headers } = - await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/events/`, - { - params: { - dataset: "spans", - field: TRANSACTION_FIELDS, - project: - numericProjectId === undefined - ? undefined - : String(numericProjectId), - // Convert empty string to undefined so ky omits the param entirely; - // sending `query=` causes the Sentry API to behave differently than - // omitting the parameter. - query: fullQuery || undefined, - per_page: perPage, - statsPeriod: - options.start || options.end - ? undefined - : (options.statsPeriod ?? "7d"), - start: options.start, - end: options.end, - sort: options.sort === "duration" ? "-span.duration" : "-timestamp", - cursor: options.cursor, - }, - schema: TransactionsResponseSchema, - }, - ); - - const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); - return { data: response.data, nextCursor }; + const result = await listOrganizationEvents({ + ...(await getOrgSdkConfig(orgSlug)), + path: { organization_id_or_slug: orgSlug }, + query: { + dataset: "spans", + field: TRANSACTION_FIELDS, + project: + numericProjectId === undefined ? undefined : [String(numericProjectId)], + // Convert empty string to undefined so the param is omitted entirely; + // sending `query=` causes the Sentry API to behave differently than + // omitting the parameter. + query: fullQuery || undefined, + per_page: perPage, + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "7d"), + start: options.start, + end: options.end, + sort: options.sort === "duration" ? "-span.duration" : "-timestamp", + cursor: options.cursor, + }, + }); + const page = unwrapPaginatedResult( + result, + "Failed to list transactions", + ); + const response: TransactionsResponse = validateResponse( + TransactionsResponseSchema, + page.data, + `/organizations/${orgSlug}/events/`, + page.response?.status ?? 0, + ); + return { data: response.data, nextCursor: page.nextCursor }; } /** @@ -639,10 +644,8 @@ export async function listTransactions( projectSlug: string, options: ListTransactionsOptions = {}, ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); return await paginate(options, (perPage, cursor) => fetchTransactionsPage( - regionUrl, orgSlug, projectSlug, { ...options, cursor }, @@ -703,7 +706,6 @@ type ListSpansOptions = { * multi-page (auto-paginating) fetches. */ async function fetchSpansPage( - regionUrl: string, orgSlug: string, projectSlug: string, options: ListSpansOptions, @@ -731,31 +733,33 @@ async function fetchSpansPage( projectParam = String(numericProjectId); } - const { data: response, headers } = await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/events/`, - { - params: { - dataset: "spans", - field: fields, - project: projectParam, - query: fullQuery || undefined, - per_page: perPage, - statsPeriod: - options.start || options.end - ? undefined - : (options.statsPeriod ?? "7d"), - start: options.start, - end: options.end, - sort: options.sort === "duration" ? "-span.duration" : "-timestamp", - cursor: options.cursor, - }, - schema: SpansResponseSchema, + const result = await listOrganizationEvents({ + ...(await getOrgSdkConfig(orgSlug)), + path: { organization_id_or_slug: orgSlug }, + query: { + dataset: "spans", + field: fields, + project: projectParam === undefined ? undefined : [projectParam], + query: fullQuery || undefined, + per_page: perPage, + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "7d"), + start: options.start, + end: options.end, + sort: options.sort === "duration" ? "-span.duration" : "-timestamp", + cursor: options.cursor, }, + }); + const page = unwrapPaginatedResult(result, "Failed to list spans"); + const response: SpansResponse = validateResponse( + SpansResponseSchema, + page.data, + `/organizations/${orgSlug}/events/`, + page.response?.status ?? 0, ); - - const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); - return { data: response.data, nextCursor }; + return { data: response.data, nextCursor: page.nextCursor }; } /** @@ -775,14 +779,7 @@ export async function listSpans( projectSlug: string, options: ListSpansOptions = {}, ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); return await paginate(options, (perPage, cursor) => - fetchSpansPage( - regionUrl, - orgSlug, - projectSlug, - { ...options, cursor }, - perPage, - ), + fetchSpansPage(orgSlug, projectSlug, { ...options, cursor }, perPage), ); } diff --git a/packages/cli/test/lib/api/chunk-upload.test.ts b/packages/cli/test/lib/api/chunk-upload.test.ts index c42461be5..18f26fba7 100644 --- a/packages/cli/test/lib/api/chunk-upload.test.ts +++ b/packages/cli/test/lib/api/chunk-upload.test.ts @@ -1,33 +1,63 @@ /** - * Tests for the chunk-upload server-options schema. + * Tests for the chunk-upload server options, the assemble requests, and + * assembly polling. * * Focuses on the optional `maxFileSize` / `maxWait` fields added for * `debug-files upload`: a server response that omits them must still parse - * (backward compatibility), and present values must be carried through. + * (backward compatibility), and present values must be carried through. The + * request functions are exercised against a mocked `fetch`. */ import { safeParse } from "valibot"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { + type AssembleResponse, + assembleArtifactBundle, + assembleDebugFiles, ChunkServerOptionsSchema, getChunkUploadOptions, pollAssembly, } from "../../../src/lib/api/chunk-upload.js"; -import { apiRequestToRegion } from "../../../src/lib/api/infrastructure.js"; -import { resolveOrgRegion } from "../../../src/lib/region.js"; - -vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("../../../src/lib/api/infrastructure.js") - >(); - return { ...actual, apiRequestToRegion: vi.fn() }; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("api-chunk-upload-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + setAuthToken("test-token"); + setOrgRegion("my-org", "https://us.sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; }); -vi.mock("../../../src/lib/region.js", () => ({ - resolveOrgRegion: vi.fn().mockResolvedValue("https://sentry.example.com"), -})); -const apiMock = vi.mocked(apiRequestToRegion); -const regionMock = vi.mocked(resolveOrgRegion); + +/** Captured request from {@link respondWith}. */ +type CapturedRequest = { url: string; method: string; body: unknown }; + +/** Answer every request with `body` and record what was sent. */ +function respondWith(body: unknown, status = 200): CapturedRequest[] { + const requests: CapturedRequest[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + const text = await req.text(); + requests.push({ + url: req.url, + method: req.method, + body: text ? JSON.parse(text) : undefined, + }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }); + return requests; +} const BASE = { url: "https://us.sentry.io/api/0/chunk-upload/", @@ -39,36 +69,99 @@ const BASE = { compression: ["gzip"], }; -describe("getChunkUploadOptions: credential stripping", () => { - beforeEach(() => { - apiMock.mockReset(); - regionMock.mockResolvedValue("https://sentry.example.com"); - }); - +describe("getChunkUploadOptions", () => { test("strips embedded credentials from the server-returned chunk-upload URL", async () => { - apiMock.mockResolvedValue({ - data: { - ...BASE, - url: "https://08d1f409cc5d402783b7ce48189675e8@bugsink.example.com/8/api/0/organizations/my-org/chunk-upload/", - }, - } as unknown as Awaited>); + const requests = respondWith({ + ...BASE, + url: "https://08d1f409cc5d402783b7ce48189675e8@bugsink.example.com/8/api/0/organizations/my-org/chunk-upload/", + }); const result = await getChunkUploadOptions("my-org"); expect(result.url).toBe( "https://bugsink.example.com/8/api/0/organizations/my-org/chunk-upload/", ); + expect(requests[0]?.url).toBe( + "https://us.sentry.io/api/0/organizations/my-org/chunk-upload/", + ); }); test("leaves a credential-free URL unchanged", async () => { - apiMock.mockResolvedValue({ - data: { ...BASE }, - } as unknown as Awaited>); + respondWith({ ...BASE }); const result = await getChunkUploadOptions("my-org"); expect(result.url).toBe(BASE.url); }); + + test("keeps maxWait, which the generated response type lacks", async () => { + respondWith({ ...BASE, maxWait: 300 }); + + const result = await getChunkUploadOptions("my-org"); + + expect(result.maxWait).toBe(300); + }); + + test("rejects a response that does not match the schema", async () => { + respondWith({ url: BASE.url }); + + await expect(getChunkUploadOptions("my-org")).rejects.toThrow( + "Unexpected response format from organizations/my-org/chunk-upload/", + ); + }); + + test("throws an ApiError on an HTTP error", async () => { + respondWith({ detail: "Organization not found" }, 404); + + const error = await getChunkUploadOptions("my-org").catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(404); + expect((error as ApiError).detail).toBe("Organization not found"); + }); +}); + +describe("assemble requests", () => { + test("assembleDebugFiles posts the body to the project DIF endpoint", async () => { + const requests = respondWith({ abc: { state: "ok", missingChunks: [] } }); + const body = { + abc: { name: "libfoo.so", debug_id: "id-1", chunks: ["c1"] }, + }; + + const result = await assembleDebugFiles("my-org", "my-project", body); + + expect(result).toEqual({ abc: { state: "ok", missingChunks: [] } }); + expect(requests).toEqual([ + { + url: "https://us.sentry.io/api/0/projects/my-org/my-project/files/difs/assemble/", + method: "POST", + body, + }, + ]); + }); + + test("assembleArtifactBundle posts the body to the org bundle endpoint", async () => { + const requests = respondWith({ state: "created", missingChunks: [] }); + const body = { checksum: "abc", chunks: ["c1"], projects: ["my-project"] }; + + const result = await assembleArtifactBundle("my-org", body); + + expect(result.state).toBe("created"); + expect(requests).toEqual([ + { + url: "https://us.sentry.io/api/0/organizations/my-org/artifactbundle/assemble/", + method: "POST", + body, + }, + ]); + }); + + test("assembleArtifactBundle rejects an unknown assemble state", async () => { + respondWith({ state: "bogus", missingChunks: [] }); + + await expect( + assembleArtifactBundle("my-org", { checksum: "abc", chunks: [] }), + ).rejects.toThrow("Unexpected response format"); + }); }); describe("ChunkServerOptionsSchema", () => { @@ -96,43 +189,37 @@ describe("ChunkServerOptionsSchema", () => { }); describe("pollAssembly: waitForOk", () => { + const assemble = vi.fn<() => Promise>(); const params = { - regionUrl: "https://us.sentry.io", + assemble, endpoint: "org/artifactbundle/assemble/", - body: {}, entityName: "Artifact bundle", }; beforeEach(() => { vi.useFakeTimers(); - apiMock.mockReset(); + assemble.mockReset(); }); afterEach(() => { vi.useRealTimers(); }); test("default (waitForOk=false) returns as soon as state is 'created'", async () => { - apiMock.mockResolvedValue({ - data: { state: "created" }, - } as unknown as Awaited>); + assemble.mockResolvedValue({ state: "created" }); const promise = pollAssembly(params); await vi.advanceTimersByTimeAsync(1000); await expect(promise).resolves.toBeUndefined(); - expect(apiMock).toHaveBeenCalledTimes(1); + expect(assemble).toHaveBeenCalledTimes(1); }); test("waitForOk=true keeps polling through 'created' until 'ok'", async () => { - apiMock - .mockResolvedValueOnce({ - data: { state: "created" }, - } as unknown as Awaited>) - .mockResolvedValueOnce({ - data: { state: "ok" }, - } as unknown as Awaited>); + assemble + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "ok" }); const promise = pollAssembly({ ...params, waitForOk: true }); await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000); await expect(promise).resolves.toBeUndefined(); - expect(apiMock).toHaveBeenCalledTimes(2); + expect(assemble).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/cli/test/lib/api/debug-files.test.ts b/packages/cli/test/lib/api/debug-files.test.ts index acd57f8ef..fc3113855 100644 --- a/packages/cli/test/lib/api/debug-files.test.ts +++ b/packages/cli/test/lib/api/debug-files.test.ts @@ -1,7 +1,7 @@ /** * Tests for the debug information file upload API. * - * Mocks the chunk-upload primitives and the region request layer so the + * Mocks the chunk-upload primitives and the assemble request so the * assemble body shape, missing-chunk upload, and the no-wait/wait completion * modes can be exercised without a network. `hashBuffer` and `pickUploadEncoding` * remain real (they are pure). @@ -16,8 +16,8 @@ import { } from "../../../src/lib/api/debug-files.js"; import { ApiError } from "../../../src/lib/errors.js"; -const { apiRequestToRegionMock, uploadChunkPayloadsMock } = vi.hoisted(() => ({ - apiRequestToRegionMock: vi.fn(), +const { assembleMock, uploadChunkPayloadsMock } = vi.hoisted(() => ({ + assembleMock: vi.fn(), uploadChunkPayloadsMock: vi.fn(() => Promise.resolve()), })); @@ -25,10 +25,6 @@ vi.mock("../../../src/lib/region.js", () => ({ resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), })); -vi.mock("../../../src/lib/api/infrastructure.js", () => ({ - apiRequestToRegion: apiRequestToRegionMock, -})); - vi.mock("../../../src/lib/api/chunk-upload.js", async (importOriginal) => { const actual = await importOriginal< @@ -46,6 +42,7 @@ vi.mock("../../../src/lib/api/chunk-upload.js", async (importOriginal) => { compression: ["gzip"], })), uploadChunkPayloads: uploadChunkPayloadsMock, + assembleDebugFiles: assembleMock, }; }); @@ -63,9 +60,9 @@ function lastAssembleBody(): Record< string, { name: string; debug_id?: string; chunks: string[] } > { - const calls = apiRequestToRegionMock.mock.calls; + const calls = assembleMock.mock.calls; const lastCall = calls.at(-1); - return lastCall?.[2].body; + return lastCall?.[2]; } const SOLE = { @@ -93,7 +90,7 @@ function setServerOptions(overrides: Record): void { } beforeEach(() => { - apiRequestToRegionMock.mockReset(); + assembleMock.mockReset(); uploadChunkPayloadsMock.mockClear(); vi.mocked(getChunkUploadOptions).mockResolvedValue( BASE_SERVER_OPTIONS as Awaited>, @@ -113,7 +110,7 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }); expect(results).toEqual([]); - expect(apiRequestToRegionMock).not.toHaveBeenCalled(); + expect(assembleMock).not.toHaveBeenCalled(); }); test("assemble body keys by checksum and includes name + debug_id + chunks", async () => { @@ -121,14 +118,14 @@ describe("uploadDebugFiles", () => { // The checksum is content-derived, so we capture the request body and // echo its keys back as "ok" — server already holds the file, so no-wait // completes on the first assemble. - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -155,14 +152,14 @@ describe("uploadDebugFiles", () => { }); test("omits debug_id from the body when not provided", async () => { - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -183,21 +180,19 @@ describe("uploadDebugFiles", () => { const dif = makeDif("libbar.so", "more debug bytes", "id-1"); // First assemble: server is missing the chunk. Second: held (created). - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const key = Object.keys(body)[0] as string; return { - data: { - [key]: { state: "not_found", missingChunks: body[key]?.chunks }, - }, + [key]: { state: "not_found", missingChunks: body[key]?.chunks }, }; }, ); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "created" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "created" } }; }, ); @@ -211,7 +206,7 @@ describe("uploadDebugFiles", () => { const results = await promise; expect(uploadChunkPayloadsMock).toHaveBeenCalled(); - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); + expect(assembleMock).toHaveBeenCalledTimes(2); expect(results[0]?.state).toBe("created"); }); @@ -219,16 +214,16 @@ describe("uploadDebugFiles", () => { vi.useFakeTimers(); const dif = makeDif("libbaz.so", "debug bytes", "id-2"); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "assembling" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "assembling" } }; }, ); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "ok" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "ok" } }; }, ); @@ -241,18 +236,16 @@ describe("uploadDebugFiles", () => { await vi.runAllTimersAsync(); const results = await promise; - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); + expect(assembleMock).toHaveBeenCalledTimes(2); expect(results[0]?.state).toBe("ok"); }); test("wait mode collects an 'error' state without throwing", async () => { const dif = makeDif("broken.so", "debug bytes", "id-3"); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { - data: { [key]: { state: "error", detail: "corrupt object" } }, - }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "error", detail: "corrupt object" } }; }, ); @@ -270,10 +263,10 @@ describe("uploadDebugFiles", () => { test("wait mode throws ApiError when assembly does not finish in time", async () => { const dif = makeDif("slow.so", "debug bytes", "id-4"); // Always "assembling" → never terminal. maxWaitMs=0 trips the deadline. - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "assembling" } } }; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "assembling" } }; }, ); @@ -288,14 +281,14 @@ describe("uploadDebugFiles", () => { }); test("batches multiple files into one assemble request", async () => { - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -306,20 +299,20 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }); - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(1); + expect(assembleMock).toHaveBeenCalledTimes(1); expect(Object.keys(lastAssembleBody())).toHaveLength(2); expect(results).toHaveLength(2); }); test("includes code_id and shader_group only when provided", async () => { - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -361,14 +354,14 @@ describe("uploadDebugFiles", () => { }); test("splits the assemble request into batches and merges the responses", async () => { - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); const difs = Array.from({ length: ASSEMBLE_BATCH_SIZE + 1 }, (_, i) => @@ -382,9 +375,9 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }); - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); - const sizes = apiRequestToRegionMock.mock.calls.map( - (call) => Object.keys(call[2].body as object).length, + expect(assembleMock).toHaveBeenCalledTimes(2); + const sizes = assembleMock.mock.calls.map( + (call) => Object.keys(call[2] as object).length, ); expect(sizes).toEqual([ASSEMBLE_BATCH_SIZE, 1]); expect(results).toHaveLength(ASSEMBLE_BATCH_SIZE + 1); @@ -395,15 +388,15 @@ describe("uploadDebugFiles", () => { // First call: server has no record of either file (entries missing). // Second call: server holds both files. No-wait completes on the second // call after the chunks are re-uploaded. - apiRequestToRegionMock.mockImplementationOnce(async () => ({ data: {} })); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce(async () => ({})); + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -426,16 +419,16 @@ describe("uploadDebugFiles", () => { // Server returns `not_found` but omits the `missingChunks` field — the // client must still upload all chunks for that file, otherwise the loop // would poll forever without sending anything. - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "not_found" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "not_found" } }; }, ); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "ok" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "ok" } }; }, ); @@ -454,14 +447,14 @@ describe("uploadDebugFiles", () => { test("does not assemble oversized files but reports them as failures", async () => { setServerOptions({ maxFileSize: 5 }); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -502,7 +495,7 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }), ).rejects.toThrow(/exceed the maximum file size/); - expect(apiRequestToRegionMock).not.toHaveBeenCalled(); + expect(assembleMock).not.toHaveBeenCalled(); }); test("clamps the wait to the server maxWait (reflected in the timeout)", async () => { @@ -510,10 +503,10 @@ describe("uploadDebugFiles", () => { vi.useFakeTimers(); // Always "assembling" → never terminal. The clamped 1s deadline trips // well before the requested 60s. - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "assembling" } } }; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "assembling" } }; }, ); @@ -533,10 +526,10 @@ describe("uploadDebugFiles", () => { test("does not clamp when the server advertises no maxWait (0)", async () => { setServerOptions({ maxWait: 0 }); const dif = makeDif("ok.so", "debug bytes", "id-ok"); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "ok" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "ok" } }; }, ); diff --git a/packages/cli/test/lib/api/preprod-artifacts.test.ts b/packages/cli/test/lib/api/preprod-artifacts.test.ts index dca4acca5..3f2ee2630 100644 --- a/packages/cli/test/lib/api/preprod-artifacts.test.ts +++ b/packages/cli/test/lib/api/preprod-artifacts.test.ts @@ -1,8 +1,10 @@ /** * Tests for the preprod-artifacts (mobile build) API. * - * Pure URL helpers are tested directly. `getBuildInstallDetails` mocks the - * region request layer; `downloadBuildArtifact` mocks `customFetch` and the + * Pure URL helpers are tested directly. Calls that use `@sentry/api` + * operations get an SDK config whose `fetch` is mocked; calls still on + * `apiRequestToRegion` mock that helper. `downloadBuildArtifact` mocks + * `customFetch` and the * auth-token getter so the streaming write and — critically — the * same-origin-only auth attachment can be verified without a network. */ @@ -19,22 +21,36 @@ const { apiRequestToRegionMock, apiRequestToRegionNoContentMock, getAuthTokenMock, + sdkFetchMock, uploadMissingChunksMock, } = vi.hoisted(() => ({ customFetchMock: vi.fn(), apiRequestToRegionMock: vi.fn(), apiRequestToRegionNoContentMock: vi.fn(() => Promise.resolve()), getAuthTokenMock: vi.fn<() => string | undefined>(() => "secret-token"), + sdkFetchMock: vi.fn<(request: Request) => Promise>(), uploadMissingChunksMock: vi.fn(() => Promise.resolve()), })); vi.mock("../../../src/lib/region.js", () => ({ resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), })); -vi.mock("../../../src/lib/api/infrastructure.js", () => ({ - apiRequestToRegion: apiRequestToRegionMock, - apiRequestToRegionNoContent: apiRequestToRegionNoContentMock, -})); +vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/api/infrastructure.js") + >(); + return { + ...actual, + apiRequestToRegion: apiRequestToRegionMock, + apiRequestToRegionNoContent: apiRequestToRegionNoContentMock, + getOrgSdkConfig: vi.fn(async () => ({ + baseUrl: "https://us.sentry.io", + fetch: sdkFetchMock, + throwOnError: false as const, + })), + }; +}); vi.mock("../../../src/lib/custom-ca.js", () => ({ customFetch: customFetchMock, })); @@ -105,25 +121,31 @@ describe("buildFormatFromUrl", () => { describe("getBuildInstallDetails", () => { beforeEach(() => { - apiRequestToRegionMock.mockReset(); + sdkFetchMock.mockReset(); }); test("hits the install-details endpoint and returns parsed data", async () => { - apiRequestToRegionMock.mockResolvedValue({ - data: { isInstallable: true, installUrl: "https://u/" }, - headers: new Headers(), - }); + sdkFetchMock.mockResolvedValue( + Response.json({ isInstallable: true, installUrl: "https://u/" }), + ); const result = await getBuildInstallDetails("my-org", "build 1"); expect(result).toEqual({ isInstallable: true, installUrl: "https://u/" }); - const lastCall = apiRequestToRegionMock.mock.calls.at(-1); - expect(lastCall?.[0]).toBe("https://us.sentry.io"); + const request = sdkFetchMock.mock.calls.at(-1)?.[0]; + expect(request?.method).toBe("GET"); // Build id is URL-encoded into the path. - expect(lastCall?.[1]).toBe( - "organizations/my-org/preprodartifacts/build%201/install-details/", + expect(request?.url).toBe( + "https://us.sentry.io/api/0/organizations/my-org/preprodartifacts/build%201/install-details/", + ); + }); + + test("rejects a response that does not match the schema", async () => { + sdkFetchMock.mockResolvedValue(Response.json({ installUrl: 42 })); + + await expect(getBuildInstallDetails("my-org", "b1")).rejects.toThrow( + "Unexpected response format from organizations/my-org/preprodartifacts/b1/install-details/", ); - expect(lastCall?.[2]?.schema).toBeDefined(); }); }); @@ -453,11 +475,10 @@ describe("snapshots", () => { }); test("getLatestBaseSnapshot maps the response and forwards params", async () => { - // apiRequestToRegion returns schema-validated snake_case data. - apiRequestToRegionMock.mockResolvedValue({ - data: { head_artifact_id: "art-1", image_count: 5 }, - headers: new Headers(), - }); + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue( + Response.json({ head_artifact_id: "art-1", image_count: 5 }), + ); await expect( getLatestBaseSnapshot("my-org", "my-app", { @@ -466,23 +487,45 @@ describe("snapshots", () => { }), ).resolves.toEqual({ headArtifactId: "art-1", imageCount: 5 }); // app_id + optional branch/project go through as query params. - const params = apiRequestToRegionMock.mock.calls.at(-1)?.[2]?.params; - expect(params).toEqual({ + const url = new URL(sdkFetchMock.mock.calls.at(-1)?.[0].url ?? ""); + expect(url.pathname).toBe( + "/api/0/organizations/my-org/preprodartifacts/snapshots/latest-base/", + ); + expect(Object.fromEntries(url.searchParams)).toEqual({ app_id: "my-app", branch: "main", project: "proj-1", }); }); + test("getLatestBaseSnapshot returns null for a null body", async () => { + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue(Response.json(null)); + await expect(getLatestBaseSnapshot("my-org", "my-app")).resolves.toBeNull(); + }); + test("getLatestBaseSnapshot returns null on 404", async () => { - apiRequestToRegionMock.mockRejectedValue( - new ApiError("not found", 404, "", "endpoint"), + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue( + Response.json({ detail: "Not found" }, { status: 404 }), ); await expect( getLatestBaseSnapshot("my-org", "missing"), ).resolves.toBeNull(); }); + test("getLatestBaseSnapshot rethrows other HTTP errors", async () => { + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue( + Response.json({ detail: "Boom" }, { status: 500 }), + ); + const error = await getLatestBaseSnapshot("my-org", "my-app").catch( + (e) => e, + ); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(500); + }); + test("waitForSnapshotArchive triggers a build then resolves when ready", async () => { apiRequestToRegionMock .mockResolvedValueOnce({ data: { ready: false }, headers: new Headers() }) diff --git a/packages/cli/test/lib/api/proguard.test.ts b/packages/cli/test/lib/api/proguard.test.ts index ed2988e8f..4b63eb493 100644 --- a/packages/cli/test/lib/api/proguard.test.ts +++ b/packages/cli/test/lib/api/proguard.test.ts @@ -5,28 +5,14 @@ * ProGuard mappings are chunked as raw bytes (no ZIP wrapping). */ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { hashBuffer } from "../../../src/lib/api/chunk-upload.js"; -import { apiRequestToRegion } from "../../../src/lib/api/infrastructure.js"; import { uploadProguardMappings } from "../../../src/lib/api/proguard.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; -vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("../../../src/lib/api/infrastructure.js") - >(); - return { ...actual, apiRequestToRegion: vi.fn() }; -}); -vi.mock("../../../src/lib/region.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), - }; -}); - -const apiMock = vi.mocked(apiRequestToRegion); +useTestConfigDir("api-proguard-"); describe("hashBuffer", () => { test("single chunk for small content", () => { @@ -106,28 +92,38 @@ describe("uploadProguardMappings", () => { compression: ["gzip"], }; + let originalFetch: typeof globalThis.fetch; + beforeEach(() => { - apiMock.mockReset(); + originalFetch = globalThis.fetch; + setAuthToken("test-token"); + setOrgRegion("test-org", "https://us.sentry.io"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; }); test("assemble request names each mapping with a leading-slash /proguard/ path", async () => { let assembleBody: Record = {}; - apiMock.mockImplementation( - async (_regionUrl, endpoint, options?: { body?: unknown }) => { - if (endpoint.includes("chunk-upload/")) { - return { data: CHUNK_UPLOAD_OPTIONS } as never; - } - // DIF assemble endpoint: report every checksum as already "ok" so the - // upload short-circuits without needing to mock chunk uploads too. - assembleBody = options?.body as typeof assembleBody; - const response: Record = {}; - for (const checksum of Object.keys(assembleBody)) { - response[checksum] = { state: "ok" }; - } - return { data: response } as never; - }, - ); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.endsWith("/chunk-upload/")) { + return Response.json(CHUNK_UPLOAD_OPTIONS); + } + // DIF assemble endpoint: report every checksum as already "ok" so the + // upload short-circuits without needing to mock chunk uploads too. + expect(req.url).toBe( + "https://us.sentry.io/api/0/projects/test-org/test-project/files/difs/assemble/", + ); + assembleBody = (await req.json()) as typeof assembleBody; + const response: Record = {}; + for (const checksum of Object.keys(assembleBody)) { + response[checksum] = { state: "ok" }; + } + return Response.json(response); + }); await uploadProguardMappings({ org: "test-org", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0527ff5c..fb33914d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 1.26.0 - version: 1.26.0 + specifier: 1.44.0 + version: 1.44.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -513,7 +513,7 @@ importers: version: 2.1.5(hono@4.13.13) '@sentry/api': specifier: 'catalog:' - version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -865,7 +865,7 @@ importers: version: 2.4.0 '@sentry/api': specifier: 'catalog:' - version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -1048,7 +1048,7 @@ importers: dependencies: '@sentry/api': specifier: 'catalog:' - version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/search-syntax': specifier: 'catalog:' version: 1.0.0 @@ -4275,8 +4275,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@1.26.0': - resolution: {integrity: sha512-LkzMT2mn2Qrbnf2s+2t/mrXpN1/2o0GO2fodhds2Ntp8kRt/NkilWIETAGUU6vB7XKlLhPZloZT4Yv/HEzCmFA==} + '@sentry/api@1.44.0': + resolution: {integrity: sha512-8PPlt5YuYLOn3btCatpogIa+Q2NW9dmtB5JPJA6IF7fKNLPUHqkojVBfOSNUb9fD5UE0ZxUzZsBUnVhW5/JvrQ==} engines: {node: '>=22'} peerDependencies: typescript: '*' @@ -7691,6 +7691,10 @@ packages: resolution: {integrity: sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==} engines: {node: '>=20'} + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -12108,9 +12112,10 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': dependencies: http-cache-semantics: 4.2.0 + p-limit: 7.3.3 optionalDependencies: typescript: 5.9.3 valibot: 1.4.2(typescript@5.9.3) @@ -16149,6 +16154,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aebcf7b2e..d198de786 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,7 +51,7 @@ catalog: "@modelcontextprotocol/server": 2.4.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 1.26.0 + "@sentry/api": 1.44.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -168,7 +168,7 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@1.26.0" + - "@sentry/api@1.44.0" - miniflare@5.20261101.0-beta - wrangler@4.156.1 - "@sentry/symbolic@14.0.0-alpha.5"