From b1dd387b09fff0762a1dd4c1c6dd2a8da1c9f59c Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:35:27 -0400 Subject: [PATCH 01/13] feat: implement TypeScript Connect API client for contract tests Replace the stub TypeScriptDirectClient with a full implementation that makes HTTP requests directly to the Connect server, mirroring the Go client's behavior. All 15 API methods are implemented and pass the same 68 contract tests that validate the Go client. Co-Authored-By: Claude Opus 4.6 --- .../src/clients/ts-direct-client.ts | 491 +++++++++++++++++- test/connect-api-contracts/src/helpers.ts | 3 +- 2 files changed, 488 insertions(+), 6 deletions(-) diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts index 0b015b7f2..a197ccfdc 100644 --- a/test/connect-api-contracts/src/clients/ts-direct-client.ts +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -3,18 +3,499 @@ import type { ConnectContractClient, ConnectContractResult, + ConnectContractStatus, MethodName, } from "../client"; +import { Method } from "../client"; +import type { CapturedRequest } from "../mock-connect-server"; /** - * Stub client for the future TypeScript ConnectClient implementation. - * As TS client methods get implemented, the call() dispatcher fills in. + * Error class that carries a structured result alongside the error message. + * Used for methods like TestAuthentication that return both a result and an + * error status (e.g. { user: null, error: { msg: "..." } }). + */ +class ClientError extends Error { + constructor( + message: string, + public result: unknown = undefined, + ) { + super(message); + } +} + +/** + * TypeScript implementation of the Connect API client. + * Makes HTTP requests directly to the Connect server (or mock) and returns + * results in the same shape as the Go publisher client. */ export class TypeScriptDirectClient implements ConnectContractClient { + constructor( + private connectUrl: string, + private apiKey: string, + ) {} + async call( - _method: MethodName, - _params?: Record, + method: MethodName, + params?: Record, ): Promise { - throw new Error("Not implemented yet"); + // Clear captured requests before the call + await this.clearCapturedRequests(); + + let result: unknown = undefined; + let status: ConnectContractStatus = "success"; + + try { + result = await this.dispatch(method, params ?? {}); + } catch (err) { + status = "error"; + if (err instanceof ClientError) { + result = err.result; + } + } + + // Fetch captured requests after the call + const capturedRequests = await this.fetchCapturedRequests(); + + return { + status, + result, + capturedRequest: capturedRequests.length > 0 ? capturedRequests[0] : null, + capturedRequests, + }; + } + + private async dispatch( + method: MethodName, + params: Record, + ): Promise { + switch (method) { + case Method.TestAuthentication: + return this.testAuthentication(); + case Method.GetCurrentUser: + return this.getCurrentUser(); + case Method.ContentDetails: + return this.contentDetails(params.contentId as string); + case Method.CreateDeployment: + return this.createDeployment( + (params.body as Record) ?? {}, + ); + case Method.UpdateDeployment: + return this.updateDeployment( + params.contentId as string, + (params.body as Record) ?? {}, + ); + case Method.GetEnvVars: + return this.getEnvVars(params.contentId as string); + case Method.SetEnvVars: + return this.setEnvVars( + params.contentId as string, + params.env as Record, + ); + case Method.UploadBundle: + return this.uploadBundle( + params.contentId as string, + params.bundleData as Uint8Array, + ); + case Method.LatestBundleID: + return this.latestBundleID(params.contentId as string); + case Method.DownloadBundle: + return this.downloadBundle( + params.contentId as string, + params.bundleId as string, + ); + case Method.DeployBundle: + return this.deployBundle( + params.contentId as string, + params.bundleId as string, + ); + case Method.WaitForTask: + return this.waitForTask(params.taskId as string); + case Method.ValidateDeployment: + return this.validateDeployment(params.contentId as string); + case Method.GetIntegrations: + return this.getIntegrations(); + case Method.GetSettings: + return this.getSettings(); + default: + throw new Error(`Unknown method: ${method}`); + } + } + + // --------------------------------------------------------------------------- + // HTTP helpers + // --------------------------------------------------------------------------- + + private async request( + method: string, + path: string, + options?: { + body?: unknown; + contentType?: string; + rawBody?: Uint8Array; + }, + ): Promise { + const url = `${this.connectUrl}${path}`; + const headers: Record = { + Authorization: `Key ${this.apiKey}`, + }; + + let body: BodyInit | undefined; + if (options?.rawBody) { + headers["Content-Type"] = + options.contentType ?? "application/octet-stream"; + body = Buffer.from(options.rawBody); + } else if (options?.body !== undefined) { + headers["Content-Type"] = options?.contentType ?? "application/json"; + body = JSON.stringify(options.body); + } + + return fetch(url, { method, headers, body }); + } + + private async getJSON(path: string): Promise { + const resp = await this.request("GET", path); + if (!resp.ok) { + throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); + } + return resp.json() as Promise; + } + + private async postJSON(path: string, body: unknown): Promise { + const resp = await this.request("POST", path, { body }); + if (!resp.ok) { + throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); + } + return resp.json() as Promise; + } + + private async patchJSON(path: string, body: unknown): Promise { + const resp = await this.request("PATCH", path, { body }); + if (!resp.ok) { + throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); + } + } + + // --------------------------------------------------------------------------- + // Mock server test infrastructure + // --------------------------------------------------------------------------- + + private async clearCapturedRequests(): Promise { + await fetch(`${this.connectUrl}/__test__/requests`, { method: "DELETE" }); + } + + private async fetchCapturedRequests(): Promise { + const resp = await fetch(`${this.connectUrl}/__test__/requests`); + return resp.json() as Promise; + } + + // --------------------------------------------------------------------------- + // API method implementations + // --------------------------------------------------------------------------- + + /** + * Validates credentials and checks user state (locked, confirmed, role). + * Returns { user, error } on both success and failure, matching the Go + * client's TestAuthentication contract. + */ + private async testAuthentication(): Promise<{ + user: { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + } | null; + error: { msg: string } | null; + }> { + const resp = await this.request("GET", "/__api__/v1/user"); + + if (!resp.ok) { + const errorBody = (await resp.json().catch(() => ({}))) as Record< + string, + unknown + >; + const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`; + throw new ClientError(msg, { user: null, error: { msg } }); + } + + const dto = (await resp.json()) as { + guid: string; + username: string; + first_name: string; + last_name: string; + email: string; + user_role: string; + confirmed: boolean; + locked: boolean; + }; + + if (dto.locked) { + const msg = `user account ${dto.username} is locked`; + throw new ClientError(msg, { user: null, error: { msg } }); + } + + if (!dto.confirmed) { + const msg = `user account ${dto.username} is not confirmed`; + throw new ClientError(msg, { user: null, error: { msg } }); + } + + if (dto.user_role !== "publisher" && dto.user_role !== "administrator") { + const msg = `user account ${dto.username} with role '${dto.user_role}' does not have permission to publish content`; + throw new ClientError(msg, { user: null, error: { msg } }); + } + + return { + user: { + id: dto.guid, + username: dto.username, + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + }, + error: null, + }; + } + + /** + * Retrieves the current authenticated user without validation checks. + */ + private async getCurrentUser(): Promise<{ + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + }> { + const dto = await this.getJSON<{ + guid: string; + username: string; + first_name: string; + last_name: string; + email: string; + }>("/__api__/v1/user"); + + return { + id: dto.guid, + username: dto.username, + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + }; + } + + /** + * Fetches details for a content item by ID. + */ + private async contentDetails( + contentId: string, + ): Promise> { + return this.getJSON>( + `/__api__/v1/content/${contentId}`, + ); + } + + /** + * Creates a new content item (deployment) and returns its GUID. + */ + private async createDeployment( + body: Record, + ): Promise<{ contentId: string }> { + const content = await this.postJSON<{ guid: string }>( + "/__api__/v1/content", + body, + ); + return { contentId: content.guid }; + } + + /** + * Updates an existing content item. Returns void (204 no-body response). + */ + private async updateDeployment( + contentId: string, + body: Record, + ): Promise { + await this.patchJSON(`/__api__/v1/content/${contentId}`, body); + } + + /** + * Retrieves environment variable names for a content item. + */ + private async getEnvVars(contentId: string): Promise { + return this.getJSON( + `/__api__/v1/content/${contentId}/environment`, + ); + } + + /** + * Sets environment variables for a content item. + * Converts { name: value } map to [{ name, value }] array format. + */ + private async setEnvVars( + contentId: string, + env: Record, + ): Promise { + const body = Object.entries(env).map(([name, value]) => ({ name, value })); + await this.patchJSON( + `/__api__/v1/content/${contentId}/environment`, + body, + ); + } + + /** + * Uploads a bundle archive (gzip) for a content item. + */ + private async uploadBundle( + contentId: string, + bundleData: Uint8Array, + ): Promise<{ bundleId: string }> { + const resp = await this.request( + "POST", + `/__api__/v1/content/${contentId}/bundles`, + { + rawBody: bundleData, + contentType: "application/gzip", + }, + ); + if (!resp.ok) { + throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); + } + const bundle = (await resp.json()) as { id: string }; + return { bundleId: bundle.id }; + } + + /** + * Retrieves the latest bundle ID from a content item's details. + */ + private async latestBundleID( + contentId: string, + ): Promise<{ bundleId: string }> { + const content = await this.getJSON<{ bundle_id: string }>( + `/__api__/v1/content/${contentId}`, + ); + return { bundleId: content.bundle_id }; + } + + /** + * Downloads a bundle archive as raw bytes. + */ + private async downloadBundle( + contentId: string, + bundleId: string, + ): Promise { + const resp = await this.request( + "GET", + `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + ); + if (!resp.ok) { + throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); + } + const buffer = await resp.arrayBuffer(); + return new Uint8Array(buffer); + } + + /** + * Initiates deployment of a specific bundle and returns the task ID. + */ + private async deployBundle( + contentId: string, + bundleId: string, + ): Promise<{ taskId: string }> { + const result = await this.postJSON<{ task_id: string }>( + `/__api__/v1/content/${contentId}/deploy`, + { bundle_id: bundleId }, + ); + return { taskId: result.task_id }; + } + + /** + * Polls for task completion. Returns { finished: true } on success, + * throws on task failure (non-zero exit code). + */ + private async waitForTask( + taskId: string, + ): Promise<{ finished: boolean }> { + let firstLine = 0; + + for (;;) { + const task = await this.getJSON<{ + id: string; + output: string[]; + result: unknown; + finished: boolean; + code: number; + error: string; + last: number; + }>(`/__api__/v1/tasks/${taskId}?first=${firstLine}`); + + if (task.finished) { + if (task.error) { + throw new ClientError(task.error); + } + return { finished: true }; + } + + firstLine = task.last; + // In production, we'd sleep between polls. The mock always returns + // finished=true so this loop completes in one iteration. + } + } + + /** + * Validates that deployed content is reachable by hitting its content URL. + * Status >= 500 is an error; 404 and other codes are acceptable. + */ + private async validateDeployment(contentId: string): Promise { + const resp = await this.request("GET", `/content/${contentId}/`); + await resp.text(); // consume body + if (resp.status >= 500) { + throw new ClientError( + "deployed content does not seem to be running", + ); + } + } + + /** + * Retrieves OAuth integrations from the server. + */ + private async getIntegrations(): Promise { + return this.getJSON("/__api__/v1/oauth/integrations"); + } + + /** + * Fetches composite server settings from 7 separate endpoints, + * mirroring the Go client's GetSettings behavior. + */ + private async getSettings(): Promise> { + const user = await this.getJSON>( + "/__api__/v1/user", + ); + const general = await this.getJSON>( + "/__api__/server_settings", + ); + const application = await this.getJSON>( + "/__api__/server_settings/applications", + ); + const scheduler = await this.getJSON>( + "/__api__/server_settings/scheduler", + ); + const python = await this.getJSON>( + "/__api__/v1/server_settings/python", + ); + const r = await this.getJSON>( + "/__api__/v1/server_settings/r", + ); + const quarto = await this.getJSON>( + "/__api__/v1/server_settings/quarto", + ); + + return { + General: general, + user, + application, + scheduler, + python, + r, + quarto, + }; } } diff --git a/test/connect-api-contracts/src/helpers.ts b/test/connect-api-contracts/src/helpers.ts index f966153f0..ebc32b9eb 100644 --- a/test/connect-api-contracts/src/helpers.ts +++ b/test/connect-api-contracts/src/helpers.ts @@ -26,7 +26,8 @@ export function getClient(): ConnectContractClient { const connectUrl = getMockConnectUrl(); _client = new GoPublisherClient(apiBase, connectUrl, TEST_API_KEY); } else { - _client = new TypeScriptDirectClient(); + const connectUrl = getMockConnectUrl(); + _client = new TypeScriptDirectClient(connectUrl, TEST_API_KEY); } return _client; } From 91857c3f4e62d80f91ce4170428197708385d479 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:05:42 -0400 Subject: [PATCH 02/13] feat: extract Connect client into packages/connect-client/ Move the TypeScript Connect API client from the contract test file into a standalone @posit-dev/connect-client package with proper types, error classes, and barrel exports. The contract test adapter becomes a thin wrapper that imports from the new package. All 68 contract tests pass with both Go and TypeScript backends. Co-Authored-By: Claude Opus 4.6 --- packages/connect-client/package-lock.json | 47 ++ packages/connect-client/package.json | 11 + packages/connect-client/src/client.ts | 355 +++++++++++++ packages/connect-client/src/errors.ts | 52 ++ packages/connect-client/src/index.ts | 40 ++ packages/connect-client/src/types.ts | 284 ++++++++++ packages/connect-client/tsconfig.json | 15 + test/connect-api-contracts/package-lock.json | 15 + test/connect-api-contracts/package.json | 3 + .../src/clients/ts-direct-client.ts | 488 +++--------------- 10 files changed, 898 insertions(+), 412 deletions(-) create mode 100644 packages/connect-client/package-lock.json create mode 100644 packages/connect-client/package.json create mode 100644 packages/connect-client/src/client.ts create mode 100644 packages/connect-client/src/errors.ts create mode 100644 packages/connect-client/src/index.ts create mode 100644 packages/connect-client/src/types.ts create mode 100644 packages/connect-client/tsconfig.json diff --git a/packages/connect-client/package-lock.json b/packages/connect-client/package-lock.json new file mode 100644 index 000000000..782b1bf6b --- /dev/null +++ b/packages/connect-client/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "@posit-dev/connect-client", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@posit-dev/connect-client", + "version": "0.0.1", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/connect-client/package.json b/packages/connect-client/package.json new file mode 100644 index 000000000..31e1cfd4d --- /dev/null +++ b/packages/connect-client/package.json @@ -0,0 +1,11 @@ +{ + "name": "@posit-dev/connect-client", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "src/index.ts", + "devDependencies": { + "typescript": "^5.7.0", + "@types/node": "^22.0.0" + } +} diff --git a/packages/connect-client/src/client.ts b/packages/connect-client/src/client.ts new file mode 100644 index 000000000..921ccf146 --- /dev/null +++ b/packages/connect-client/src/client.ts @@ -0,0 +1,355 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import type { + AllSettings, + ApplicationSettings, + BundleID, + ConnectClientOptions, + ConnectContent, + ContentDetailsDTO, + ContentID, + DeployOutput, + Integration, + PyInfo, + QuartoInfo, + RInfo, + SchedulerSettings, + ServerSettings, + TaskDTO, + TaskID, + User, + UserDTO, +} from "./types.js"; + +import { + AuthenticationError, + ConnectRequestError, + DeploymentValidationError, + TaskError, +} from "./errors.js"; + +/** + * TypeScript client for the Posit Connect API. + * + * Uses native fetch with zero runtime dependencies. + * Property names use snake_case to match the Connect API JSON wire format. + */ +export class ConnectClient { + private readonly url: string; + private readonly apiKey: string; + + constructor(options: ConnectClientOptions) { + this.url = options.url; + this.apiKey = options.apiKey; + } + + // --------------------------------------------------------------------------- + // HTTP helpers + // --------------------------------------------------------------------------- + + private async request( + method: string, + path: string, + options?: { + body?: unknown; + contentType?: string; + rawBody?: Uint8Array; + }, + ): Promise { + const url = `${this.url}${path}`; + const headers: Record = { + Authorization: `Key ${this.apiKey}`, + }; + + let body: BodyInit | undefined; + if (options?.rawBody) { + headers["Content-Type"] = + options.contentType ?? "application/octet-stream"; + body = Buffer.from(options.rawBody); + } else if (options?.body !== undefined) { + headers["Content-Type"] = options.contentType ?? "application/json"; + body = JSON.stringify(options.body); + } + + return fetch(url, { method, headers, body }); + } + + private async requestJson( + method: string, + path: string, + options?: { body?: unknown }, + ): Promise { + const resp = await this.request(method, path, options); + if (!resp.ok) { + const body = await resp.text(); + throw new ConnectRequestError(resp.status, resp.statusText, body); + } + return resp.json() as Promise; + } + + // --------------------------------------------------------------------------- + // API methods + // --------------------------------------------------------------------------- + + /** + * Validates credentials and checks user state (locked, confirmed, role). + * Returns `{ user, error }` instead of throwing — matches Go harness contract. + */ + async testAuthentication(): Promise<{ + user: User | null; + error: { msg: string } | null; + }> { + const resp = await this.request("GET", "/__api__/v1/user"); + + if (!resp.ok) { + const errorBody = (await resp.json().catch(() => ({}))) as Record< + string, + unknown + >; + const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`; + throw new AuthenticationError(msg); + } + + const dto = (await resp.json()) as UserDTO; + + if (dto.locked) { + const msg = `user account ${dto.username} is locked`; + throw new AuthenticationError(msg); + } + + if (!dto.confirmed) { + const msg = `user account ${dto.username} is not confirmed`; + throw new AuthenticationError(msg); + } + + if (dto.user_role !== "publisher" && dto.user_role !== "administrator") { + const msg = `user account ${dto.username} with role '${dto.user_role}' does not have permission to publish content`; + throw new AuthenticationError(msg); + } + + return { + user: { + id: dto.guid, + username: dto.username, + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + }, + error: null, + }; + } + + /** Retrieves the current authenticated user without validation checks. */ + async getCurrentUser(): Promise { + const dto = await this.requestJson("GET", "/__api__/v1/user"); + return { + id: dto.guid, + username: dto.username, + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + }; + } + + /** Fetches details for a content item by ID. */ + async contentDetails(contentId: ContentID): Promise { + return this.requestJson( + "GET", + `/__api__/v1/content/${contentId}`, + ); + } + + /** Creates a new content item and returns its GUID. */ + async createDeployment(body: ConnectContent): Promise { + const content = await this.requestJson<{ guid: string }>( + "POST", + "/__api__/v1/content", + { body }, + ); + return content.guid as ContentID; + } + + /** Updates an existing content item. */ + async updateDeployment( + contentId: ContentID, + body: ConnectContent, + ): Promise { + const resp = await this.request("PATCH", `/__api__/v1/content/${contentId}`, { + body, + }); + if (!resp.ok) { + const respBody = await resp.text(); + throw new ConnectRequestError(resp.status, resp.statusText, respBody); + } + } + + /** Retrieves environment variable names for a content item. */ + async getEnvVars(contentId: ContentID): Promise { + return this.requestJson( + "GET", + `/__api__/v1/content/${contentId}/environment`, + ); + } + + /** Sets environment variables for a content item. */ + async setEnvVars( + contentId: ContentID, + env: Record, + ): Promise { + const body = Object.entries(env).map(([name, value]) => ({ name, value })); + const resp = await this.request( + "PATCH", + `/__api__/v1/content/${contentId}/environment`, + { body }, + ); + if (!resp.ok) { + const respBody = await resp.text(); + throw new ConnectRequestError(resp.status, resp.statusText, respBody); + } + } + + /** Uploads a bundle archive (gzip) for a content item. */ + async uploadBundle( + contentId: ContentID, + data: Uint8Array, + ): Promise { + const resp = await this.request( + "POST", + `/__api__/v1/content/${contentId}/bundles`, + { rawBody: data, contentType: "application/gzip" }, + ); + if (!resp.ok) { + const body = await resp.text(); + throw new ConnectRequestError(resp.status, resp.statusText, body); + } + const bundle = (await resp.json()) as { id: string }; + return bundle.id as BundleID; + } + + /** Retrieves the latest bundle ID from a content item's details. */ + async latestBundleId(contentId: ContentID): Promise { + const content = await this.requestJson<{ bundle_id: string }>( + "GET", + `/__api__/v1/content/${contentId}`, + ); + return content.bundle_id as BundleID; + } + + /** Downloads a bundle archive as raw bytes. */ + async downloadBundle( + contentId: ContentID, + bundleId: BundleID, + ): Promise { + const resp = await this.request( + "GET", + `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + ); + if (!resp.ok) { + const body = await resp.text(); + throw new ConnectRequestError(resp.status, resp.statusText, body); + } + const buffer = await resp.arrayBuffer(); + return new Uint8Array(buffer); + } + + /** Initiates deployment of a specific bundle and returns the task ID. */ + async deployBundle( + contentId: ContentID, + bundleId: BundleID, + ): Promise { + const result = await this.requestJson( + "POST", + `/__api__/v1/content/${contentId}/deploy`, + { body: { bundle_id: bundleId } }, + ); + return result.task_id as TaskID; + } + + /** + * Polls for task completion. + * @param pollIntervalMs - milliseconds between polls (default 500, pass 0 for tests) + */ + async waitForTask( + taskId: TaskID, + pollIntervalMs = 500, + ): Promise<{ finished: true }> { + let firstLine = 0; + + for (;;) { + const task = await this.requestJson( + "GET", + `/__api__/v1/tasks/${taskId}?first=${firstLine}`, + ); + + if (task.finished) { + if (task.error) { + throw new TaskError(taskId, task.error, task.code); + } + return { finished: true }; + } + + firstLine = task.last; + + if (pollIntervalMs > 0) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } + } + + /** + * Validates that deployed content is reachable by hitting its content URL. + * Status >= 500 is an error; 404 and other codes are acceptable. + */ + async validateDeployment(contentId: ContentID): Promise { + const resp = await this.request("GET", `/content/${contentId}/`); + await resp.text(); // consume body + if (resp.status >= 500) { + throw new DeploymentValidationError(contentId, resp.status); + } + } + + /** Retrieves OAuth integrations from the server. */ + async getIntegrations(): Promise { + return this.requestJson( + "GET", + "/__api__/v1/oauth/integrations", + ); + } + + /** + * Fetches composite server settings from 7 separate endpoints, + * mirroring the Go client's GetSettings behavior. + */ + async getSettings(): Promise { + const user = await this.requestJson( + "GET", + "/__api__/v1/user", + ); + const General = await this.requestJson( + "GET", + "/__api__/server_settings", + ); + const application = await this.requestJson( + "GET", + "/__api__/server_settings/applications", + ); + const scheduler = await this.requestJson( + "GET", + "/__api__/server_settings/scheduler", + ); + const python = await this.requestJson( + "GET", + "/__api__/v1/server_settings/python", + ); + const r = await this.requestJson( + "GET", + "/__api__/v1/server_settings/r", + ); + const quarto = await this.requestJson( + "GET", + "/__api__/v1/server_settings/quarto", + ); + + return { General, user, application, scheduler, python, r, quarto }; + } +} diff --git a/packages/connect-client/src/errors.ts b/packages/connect-client/src/errors.ts new file mode 100644 index 000000000..ccfb64ab0 --- /dev/null +++ b/packages/connect-client/src/errors.ts @@ -0,0 +1,52 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** Base error class for all Connect client errors. */ +export class ConnectClientError extends Error { + constructor(message: string) { + super(message); + this.name = "ConnectClientError"; + } +} + +/** HTTP request returned a non-2xx status. */ +export class ConnectRequestError extends ConnectClientError { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly body: string, + ) { + super(`HTTP ${status}: ${statusText}`); + this.name = "ConnectRequestError"; + } +} + +/** User account is locked, unconfirmed, or has insufficient role. */ +export class AuthenticationError extends ConnectClientError { + constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + } +} + +/** Task finished with a non-zero exit code or error message. */ +export class TaskError extends ConnectClientError { + constructor( + public readonly taskId: string, + public readonly errorMessage: string, + public readonly code: number, + ) { + super(errorMessage); + this.name = "TaskError"; + } +} + +/** Deployed content URL returned a 5xx status. */ +export class DeploymentValidationError extends ConnectClientError { + constructor( + public readonly contentId: string, + public readonly httpStatus: number, + ) { + super("deployed content does not seem to be running"); + this.name = "DeploymentValidationError"; + } +} diff --git a/packages/connect-client/src/index.ts b/packages/connect-client/src/index.ts new file mode 100644 index 000000000..6665af473 --- /dev/null +++ b/packages/connect-client/src/index.ts @@ -0,0 +1,40 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +export { ConnectClient } from "./client.js"; + +export type { + AllSettings, + ApplicationSettings, + BundleDTO, + BundleID, + ConnectClientOptions, + ConnectContent, + ContentDetailsDTO, + ContentID, + DeployOutput, + EnvVar, + GUID, + Integration, + LicenseStatus, + PyInfo, + PyInstallation, + QuartoInfo, + QuartoInstallation, + RInfo, + RInstallation, + SchedulerSettings, + ServerSettings, + TaskDTO, + TaskID, + User, + UserDTO, + UserID, +} from "./types.js"; + +export { + AuthenticationError, + ConnectClientError, + ConnectRequestError, + DeploymentValidationError, + TaskError, +} from "./errors.js"; diff --git a/packages/connect-client/src/types.ts b/packages/connect-client/src/types.ts new file mode 100644 index 000000000..d78391632 --- /dev/null +++ b/packages/connect-client/src/types.ts @@ -0,0 +1,284 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +// --------------------------------------------------------------------------- +// Branded ID types — opaque strings for type safety +// --------------------------------------------------------------------------- + +export type ContentID = string & { readonly __brand: "ContentID" }; +export type BundleID = string & { readonly __brand: "BundleID" }; +export type TaskID = string & { readonly __brand: "TaskID" }; +export type UserID = string & { readonly __brand: "UserID" }; +export type GUID = string & { readonly __brand: "GUID" }; + +// --------------------------------------------------------------------------- +// Client options +// --------------------------------------------------------------------------- + +export interface ConnectClientOptions { + url: string; + apiKey: string; +} + +// --------------------------------------------------------------------------- +// User types +// --------------------------------------------------------------------------- + +/** Mapped user returned by the client (guid → id). */ +export interface User { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; +} + +/** Raw user DTO from the Connect API. */ +export interface UserDTO { + guid: string; + username: string; + first_name: string; + last_name: string; + email: string; + user_role: string; + created_time: string; + updated_time: string; + active_time: string | null; + confirmed: boolean; + locked: boolean; +} + +// --------------------------------------------------------------------------- +// Content types +// --------------------------------------------------------------------------- + +/** Content body for create/update requests. */ +export interface ConnectContent { + app_mode?: string; + name?: string; + title?: string; + guid?: string; + description?: string; + access_type?: string; + connection_timeout?: number | null; + read_timeout?: number | null; + init_timeout?: number | null; + idle_timeout?: number | null; + max_processes?: number | null; + min_processes?: number | null; + max_conns_per_process?: number | null; + load_factor?: number | null; + run_as?: string; + run_as_current_user?: boolean; + memory_request?: number | null; + memory_limit?: number | null; + cpu_request?: number | null; + cpu_limit?: number | null; + amd_gpu_limit?: number | null; + nvidia_gpu_limit?: number | null; + service_account_name?: string; + default_image_name?: string; + default_r_environment_management?: boolean; + default_py_environment_management?: boolean; + locked?: boolean; +} + +/** Content details DTO returned by GET /content/:id. */ +export interface ContentDetailsDTO { + guid: string; + name: string; + title: string | null; + description: string; + access_type: string; + connection_timeout: number | null; + read_timeout: number | null; + init_timeout: number | null; + idle_timeout: number | null; + max_processes: number | null; + min_processes: number | null; + max_conns_per_process: number | null; + load_factor: number | null; + created_time: string; + last_deployed_time: string; + bundle_id: string | null; + app_mode: string; + content_category: string; + parameterized: boolean; + cluster_name: string | null; + image_name: string | null; + r_version: string | null; + py_version: string | null; + quarto_version: string | null; + run_as: string | null; + run_as_current_user: boolean; + owner_guid: string; + content_url: string; + dashboard_url: string; + app_role: string; + id: string; +} + +// --------------------------------------------------------------------------- +// Integration type +// --------------------------------------------------------------------------- + +export interface Integration { + guid: string; + name: string; + description: string; + auth_type: string; + template: string; + config: Record; + created_time: string; +} + +// --------------------------------------------------------------------------- +// Bundle types +// --------------------------------------------------------------------------- + +export interface BundleDTO { + id: string; + content_guid: string; + created_time: string; + cluster_name: string | null; + image_name: string | null; + r_version: string | null; + py_version: string | null; + quarto_version: string | null; + active: boolean; + size: number; + metadata: { + source: string | null; + source_repo: string | null; + source_branch: string | null; + source_commit: string | null; + archive_md5: string | null; + archive_sha1: string | null; + }; +} + +// --------------------------------------------------------------------------- +// Deploy / Task types +// --------------------------------------------------------------------------- + +export interface DeployOutput { + task_id: string; +} + +export interface TaskDTO { + id: string; + output: string[]; + result: unknown; + finished: boolean; + code: number; + error: string; + last: number; +} + +// --------------------------------------------------------------------------- +// Environment variable type +// --------------------------------------------------------------------------- + +export interface EnvVar { + name: string; + value: string; +} + +// --------------------------------------------------------------------------- +// Server settings types +// --------------------------------------------------------------------------- + +export interface LicenseStatus { + "allow-apis": boolean; + "current-user-execution": boolean; + "enable-launcher": boolean; + "oauth-integrations": boolean; +} + +export interface ServerSettings { + license: LicenseStatus; + runtimes: string[]; + git_enabled: boolean; + git_available: boolean; + execution_type: string; + enable_runtime_constraints: boolean; + enable_image_management: boolean; + default_image_selection_enabled: boolean; + default_environment_management_selection: boolean; + default_r_environment_management: boolean; + default_py_environment_management: boolean; + oauth_integrations_enabled: boolean; +} + +export interface ApplicationSettings { + access_types: string[]; + run_as: string; + run_as_group: string; + run_as_current_user: boolean; +} + +export interface SchedulerSettings { + min_processes: number; + max_processes: number; + max_conns_per_process: number; + load_factor: number; + init_timeout: number; + idle_timeout: number; + min_processes_limit: number; + max_processes_limit: number; + connection_timeout: number; + read_timeout: number; + cpu_request: number; + max_cpu_request: number; + cpu_limit: number; + max_cpu_limit: number; + memory_request: number; + max_memory_request: number; + memory_limit: number; + max_memory_limit: number; + amd_gpu_limit: number; + max_amd_gpu_limit: number; + nvidia_gpu_limit: number; + max_nvidia_gpu_limit: number; +} + +export interface PyInstallation { + version: string; + cluster_name: string; + image_name: string; +} + +export interface PyInfo { + installations: PyInstallation[]; + api_enabled: boolean; +} + +export interface RInstallation { + version: string; + cluster_name: string; + image_name: string; +} + +export interface RInfo { + installations: RInstallation[]; +} + +export interface QuartoInstallation { + version: string; + cluster_name: string; + image_name: string; +} + +export interface QuartoInfo { + installations: QuartoInstallation[]; +} + +/** Composite settings from all 7 server endpoints. */ +export interface AllSettings { + General: ServerSettings; + user: UserDTO; + application: ApplicationSettings; + scheduler: SchedulerSettings; + python: PyInfo; + r: RInfo; + quarto: QuartoInfo; +} diff --git a/packages/connect-client/tsconfig.json b/packages/connect-client/tsconfig.json new file mode 100644 index 000000000..d15ad54f2 --- /dev/null +++ b/packages/connect-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/connect-api-contracts/package-lock.json b/test/connect-api-contracts/package-lock.json index 2e8fbc6e7..94e517262 100644 --- a/test/connect-api-contracts/package-lock.json +++ b/test/connect-api-contracts/package-lock.json @@ -5,6 +5,9 @@ "packages": { "": { "name": "connect-api-contracts", + "dependencies": { + "@posit-dev/connect-client": "file:../../packages/connect-client" + }, "devDependencies": { "@types/node": "^22.0.0", "ajv": "^8.18.0", @@ -13,6 +16,14 @@ "vitest": "^4.0.0" } }, + "../../packages/connect-client": { + "name": "@posit-dev/connect-client", + "version": "0.0.1", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -462,6 +473,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@posit-dev/connect-client": { + "resolved": "../../packages/connect-client", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", diff --git a/test/connect-api-contracts/package.json b/test/connect-api-contracts/package.json index a85f7c1f6..56e76485b 100644 --- a/test/connect-api-contracts/package.json +++ b/test/connect-api-contracts/package.json @@ -9,6 +9,9 @@ "validate-fixtures": "vitest run --config vitest.validate.config.ts", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@posit-dev/connect-client": "file:../../packages/connect-client" + }, "devDependencies": { "@types/node": "^22.0.0", "ajv": "^8.18.0", diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts index a197ccfdc..d59081fd9 100644 --- a/test/connect-api-contracts/src/clients/ts-direct-client.ts +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -1,5 +1,12 @@ // Copyright (C) 2026 by Posit Software, PBC. +import { + ConnectClient, + type ContentID, + type BundleID, + type TaskID, +} from "@posit-dev/connect-client"; + import type { ConnectContractClient, ConnectContractResult, @@ -10,29 +17,19 @@ import { Method } from "../client"; import type { CapturedRequest } from "../mock-connect-server"; /** - * Error class that carries a structured result alongside the error message. - * Used for methods like TestAuthentication that return both a result and an - * error status (e.g. { user: null, error: { msg: "..." } }). - */ -class ClientError extends Error { - constructor( - message: string, - public result: unknown = undefined, - ) { - super(message); - } -} - -/** - * TypeScript implementation of the Connect API client. - * Makes HTTP requests directly to the Connect server (or mock) and returns - * results in the same shape as the Go publisher client. + * Thin adapter that wraps the production ConnectClient for contract testing. + * Handles mock server request capture and maps return values to the + * contract result shapes ({ contentId }, { bundleId }, etc.). */ export class TypeScriptDirectClient implements ConnectContractClient { + private readonly connectClient: ConnectClient; + constructor( private connectUrl: string, - private apiKey: string, - ) {} + apiKey: string, + ) { + this.connectClient = new ConnectClient({ url: connectUrl, apiKey }); + } async call( method: MethodName, @@ -48,8 +45,10 @@ export class TypeScriptDirectClient implements ConnectContractClient { result = await this.dispatch(method, params ?? {}); } catch (err) { status = "error"; - if (err instanceof ClientError) { - result = err.result; + // For testAuthentication, the error carries a structured result + if (method === Method.TestAuthentication && err instanceof Error) { + const msg = err.message; + result = { user: null, error: { msg } }; } } @@ -68,113 +67,89 @@ export class TypeScriptDirectClient implements ConnectContractClient { method: MethodName, params: Record, ): Promise { + const c = this.connectClient; + switch (method) { case Method.TestAuthentication: - return this.testAuthentication(); + return c.testAuthentication(); + case Method.GetCurrentUser: - return this.getCurrentUser(); + return c.getCurrentUser(); + case Method.ContentDetails: - return this.contentDetails(params.contentId as string); - case Method.CreateDeployment: - return this.createDeployment( + return c.contentDetails(params.contentId as ContentID); + + case Method.CreateDeployment: { + const contentId = await c.createDeployment( (params.body as Record) ?? {}, ); + return { contentId }; + } + case Method.UpdateDeployment: - return this.updateDeployment( - params.contentId as string, + await c.updateDeployment( + params.contentId as ContentID, (params.body as Record) ?? {}, ); + return undefined; + case Method.GetEnvVars: - return this.getEnvVars(params.contentId as string); + return c.getEnvVars(params.contentId as ContentID); + case Method.SetEnvVars: - return this.setEnvVars( - params.contentId as string, + await c.setEnvVars( + params.contentId as ContentID, params.env as Record, ); - case Method.UploadBundle: - return this.uploadBundle( - params.contentId as string, + return undefined; + + case Method.UploadBundle: { + const bundleId = await c.uploadBundle( + params.contentId as ContentID, params.bundleData as Uint8Array, ); - case Method.LatestBundleID: - return this.latestBundleID(params.contentId as string); + return { bundleId }; + } + + case Method.LatestBundleID: { + const bundleId = await c.latestBundleId( + params.contentId as ContentID, + ); + return { bundleId }; + } + case Method.DownloadBundle: - return this.downloadBundle( - params.contentId as string, - params.bundleId as string, + return c.downloadBundle( + params.contentId as ContentID, + params.bundleId as BundleID, ); - case Method.DeployBundle: - return this.deployBundle( - params.contentId as string, - params.bundleId as string, + + case Method.DeployBundle: { + const taskId = await c.deployBundle( + params.contentId as ContentID, + params.bundleId as BundleID, ); + return { taskId }; + } + case Method.WaitForTask: - return this.waitForTask(params.taskId as string); + return c.waitForTask(params.taskId as TaskID, 0); + case Method.ValidateDeployment: - return this.validateDeployment(params.contentId as string); + await c.validateDeployment(params.contentId as ContentID); + return undefined; + case Method.GetIntegrations: - return this.getIntegrations(); + return c.getIntegrations(); + case Method.GetSettings: - return this.getSettings(); + return c.getSettings(); + default: throw new Error(`Unknown method: ${method}`); } } - // --------------------------------------------------------------------------- - // HTTP helpers - // --------------------------------------------------------------------------- - - private async request( - method: string, - path: string, - options?: { - body?: unknown; - contentType?: string; - rawBody?: Uint8Array; - }, - ): Promise { - const url = `${this.connectUrl}${path}`; - const headers: Record = { - Authorization: `Key ${this.apiKey}`, - }; - - let body: BodyInit | undefined; - if (options?.rawBody) { - headers["Content-Type"] = - options.contentType ?? "application/octet-stream"; - body = Buffer.from(options.rawBody); - } else if (options?.body !== undefined) { - headers["Content-Type"] = options?.contentType ?? "application/json"; - body = JSON.stringify(options.body); - } - - return fetch(url, { method, headers, body }); - } - - private async getJSON(path: string): Promise { - const resp = await this.request("GET", path); - if (!resp.ok) { - throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); - } - return resp.json() as Promise; - } - - private async postJSON(path: string, body: unknown): Promise { - const resp = await this.request("POST", path, { body }); - if (!resp.ok) { - throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); - } - return resp.json() as Promise; - } - - private async patchJSON(path: string, body: unknown): Promise { - const resp = await this.request("PATCH", path, { body }); - if (!resp.ok) { - throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); - } - } - // --------------------------------------------------------------------------- // Mock server test infrastructure // --------------------------------------------------------------------------- @@ -187,315 +162,4 @@ export class TypeScriptDirectClient implements ConnectContractClient { const resp = await fetch(`${this.connectUrl}/__test__/requests`); return resp.json() as Promise; } - - // --------------------------------------------------------------------------- - // API method implementations - // --------------------------------------------------------------------------- - - /** - * Validates credentials and checks user state (locked, confirmed, role). - * Returns { user, error } on both success and failure, matching the Go - * client's TestAuthentication contract. - */ - private async testAuthentication(): Promise<{ - user: { - id: string; - username: string; - first_name: string; - last_name: string; - email: string; - } | null; - error: { msg: string } | null; - }> { - const resp = await this.request("GET", "/__api__/v1/user"); - - if (!resp.ok) { - const errorBody = (await resp.json().catch(() => ({}))) as Record< - string, - unknown - >; - const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`; - throw new ClientError(msg, { user: null, error: { msg } }); - } - - const dto = (await resp.json()) as { - guid: string; - username: string; - first_name: string; - last_name: string; - email: string; - user_role: string; - confirmed: boolean; - locked: boolean; - }; - - if (dto.locked) { - const msg = `user account ${dto.username} is locked`; - throw new ClientError(msg, { user: null, error: { msg } }); - } - - if (!dto.confirmed) { - const msg = `user account ${dto.username} is not confirmed`; - throw new ClientError(msg, { user: null, error: { msg } }); - } - - if (dto.user_role !== "publisher" && dto.user_role !== "administrator") { - const msg = `user account ${dto.username} with role '${dto.user_role}' does not have permission to publish content`; - throw new ClientError(msg, { user: null, error: { msg } }); - } - - return { - user: { - id: dto.guid, - username: dto.username, - first_name: dto.first_name, - last_name: dto.last_name, - email: dto.email, - }, - error: null, - }; - } - - /** - * Retrieves the current authenticated user without validation checks. - */ - private async getCurrentUser(): Promise<{ - id: string; - username: string; - first_name: string; - last_name: string; - email: string; - }> { - const dto = await this.getJSON<{ - guid: string; - username: string; - first_name: string; - last_name: string; - email: string; - }>("/__api__/v1/user"); - - return { - id: dto.guid, - username: dto.username, - first_name: dto.first_name, - last_name: dto.last_name, - email: dto.email, - }; - } - - /** - * Fetches details for a content item by ID. - */ - private async contentDetails( - contentId: string, - ): Promise> { - return this.getJSON>( - `/__api__/v1/content/${contentId}`, - ); - } - - /** - * Creates a new content item (deployment) and returns its GUID. - */ - private async createDeployment( - body: Record, - ): Promise<{ contentId: string }> { - const content = await this.postJSON<{ guid: string }>( - "/__api__/v1/content", - body, - ); - return { contentId: content.guid }; - } - - /** - * Updates an existing content item. Returns void (204 no-body response). - */ - private async updateDeployment( - contentId: string, - body: Record, - ): Promise { - await this.patchJSON(`/__api__/v1/content/${contentId}`, body); - } - - /** - * Retrieves environment variable names for a content item. - */ - private async getEnvVars(contentId: string): Promise { - return this.getJSON( - `/__api__/v1/content/${contentId}/environment`, - ); - } - - /** - * Sets environment variables for a content item. - * Converts { name: value } map to [{ name, value }] array format. - */ - private async setEnvVars( - contentId: string, - env: Record, - ): Promise { - const body = Object.entries(env).map(([name, value]) => ({ name, value })); - await this.patchJSON( - `/__api__/v1/content/${contentId}/environment`, - body, - ); - } - - /** - * Uploads a bundle archive (gzip) for a content item. - */ - private async uploadBundle( - contentId: string, - bundleData: Uint8Array, - ): Promise<{ bundleId: string }> { - const resp = await this.request( - "POST", - `/__api__/v1/content/${contentId}/bundles`, - { - rawBody: bundleData, - contentType: "application/gzip", - }, - ); - if (!resp.ok) { - throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); - } - const bundle = (await resp.json()) as { id: string }; - return { bundleId: bundle.id }; - } - - /** - * Retrieves the latest bundle ID from a content item's details. - */ - private async latestBundleID( - contentId: string, - ): Promise<{ bundleId: string }> { - const content = await this.getJSON<{ bundle_id: string }>( - `/__api__/v1/content/${contentId}`, - ); - return { bundleId: content.bundle_id }; - } - - /** - * Downloads a bundle archive as raw bytes. - */ - private async downloadBundle( - contentId: string, - bundleId: string, - ): Promise { - const resp = await this.request( - "GET", - `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, - ); - if (!resp.ok) { - throw new ClientError(`HTTP ${resp.status}: ${resp.statusText}`); - } - const buffer = await resp.arrayBuffer(); - return new Uint8Array(buffer); - } - - /** - * Initiates deployment of a specific bundle and returns the task ID. - */ - private async deployBundle( - contentId: string, - bundleId: string, - ): Promise<{ taskId: string }> { - const result = await this.postJSON<{ task_id: string }>( - `/__api__/v1/content/${contentId}/deploy`, - { bundle_id: bundleId }, - ); - return { taskId: result.task_id }; - } - - /** - * Polls for task completion. Returns { finished: true } on success, - * throws on task failure (non-zero exit code). - */ - private async waitForTask( - taskId: string, - ): Promise<{ finished: boolean }> { - let firstLine = 0; - - for (;;) { - const task = await this.getJSON<{ - id: string; - output: string[]; - result: unknown; - finished: boolean; - code: number; - error: string; - last: number; - }>(`/__api__/v1/tasks/${taskId}?first=${firstLine}`); - - if (task.finished) { - if (task.error) { - throw new ClientError(task.error); - } - return { finished: true }; - } - - firstLine = task.last; - // In production, we'd sleep between polls. The mock always returns - // finished=true so this loop completes in one iteration. - } - } - - /** - * Validates that deployed content is reachable by hitting its content URL. - * Status >= 500 is an error; 404 and other codes are acceptable. - */ - private async validateDeployment(contentId: string): Promise { - const resp = await this.request("GET", `/content/${contentId}/`); - await resp.text(); // consume body - if (resp.status >= 500) { - throw new ClientError( - "deployed content does not seem to be running", - ); - } - } - - /** - * Retrieves OAuth integrations from the server. - */ - private async getIntegrations(): Promise { - return this.getJSON("/__api__/v1/oauth/integrations"); - } - - /** - * Fetches composite server settings from 7 separate endpoints, - * mirroring the Go client's GetSettings behavior. - */ - private async getSettings(): Promise> { - const user = await this.getJSON>( - "/__api__/v1/user", - ); - const general = await this.getJSON>( - "/__api__/server_settings", - ); - const application = await this.getJSON>( - "/__api__/server_settings/applications", - ); - const scheduler = await this.getJSON>( - "/__api__/server_settings/scheduler", - ); - const python = await this.getJSON>( - "/__api__/v1/server_settings/python", - ); - const r = await this.getJSON>( - "/__api__/v1/server_settings/r", - ); - const quarto = await this.getJSON>( - "/__api__/v1/server_settings/quarto", - ); - - return { - General: general, - user, - application, - scheduler, - python, - r, - quarto, - }; - } } From e0295aa9ca9252e0459089af77fe177cc63c4bca Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:12:08 -0400 Subject: [PATCH 03/13] style: fix prettier formatting in client files Co-Authored-By: Claude Opus 4.6 --- packages/connect-client/src/client.ts | 15 ++++++++------- .../src/clients/ts-direct-client.ts | 4 +--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/connect-client/src/client.ts b/packages/connect-client/src/client.ts index 921ccf146..1c0da447b 100644 --- a/packages/connect-client/src/client.ts +++ b/packages/connect-client/src/client.ts @@ -174,9 +174,13 @@ export class ConnectClient { contentId: ContentID, body: ConnectContent, ): Promise { - const resp = await this.request("PATCH", `/__api__/v1/content/${contentId}`, { - body, - }); + const resp = await this.request( + "PATCH", + `/__api__/v1/content/${contentId}`, + { + body, + }, + ); if (!resp.ok) { const respBody = await resp.text(); throw new ConnectRequestError(resp.status, resp.statusText, respBody); @@ -321,10 +325,7 @@ export class ConnectClient { * mirroring the Go client's GetSettings behavior. */ async getSettings(): Promise { - const user = await this.requestJson( - "GET", - "/__api__/v1/user", - ); + const user = await this.requestJson("GET", "/__api__/v1/user"); const General = await this.requestJson( "GET", "/__api__/server_settings", diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts index d59081fd9..bcf5c49d3 100644 --- a/test/connect-api-contracts/src/clients/ts-direct-client.ts +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -112,9 +112,7 @@ export class TypeScriptDirectClient implements ConnectContractClient { } case Method.LatestBundleID: { - const bundleId = await c.latestBundleId( - params.contentId as ContentID, - ); + const bundleId = await c.latestBundleId(params.contentId as ContentID); return { bundleId }; } From a0ef17cf481cdada5369f0e259a1dc7abbdab26c Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:20:07 -0400 Subject: [PATCH 04/13] ci: run contract tests with both Go and TypeScript backends Add a matrix strategy to the contract-tests job so it runs once with API_BACKEND=go (existing behavior) and once with API_BACKEND=typescript (new ConnectClient from @posit-dev/connect-client). The TypeScript backend skips Go setup since it doesn't need the harness binary. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/connect-contract-tests.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/connect-contract-tests.yaml b/.github/workflows/connect-contract-tests.yaml index b1f5447e6..ef2ce7065 100644 --- a/.github/workflows/connect-contract-tests.yaml +++ b/.github/workflows/connect-contract-tests.yaml @@ -6,9 +6,16 @@ permissions: jobs: contract-tests: runs-on: ubuntu-latest + strategy: + matrix: + backend: [go, typescript] + name: contract-tests (${{ matrix.backend }}) steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup + if: matrix.backend == 'go' + - uses: extractions/setup-just@v3 + if: matrix.backend == 'typescript' - uses: actions/setup-node@v6 with: node-version: "22" @@ -19,6 +26,8 @@ jobs: - run: npm run typecheck working-directory: test/connect-api-contracts - run: just test-connect-contracts + env: + API_BACKEND: ${{ matrix.backend }} validate-fixtures: runs-on: ubuntu-latest From 7c80a3b16be2041d17ac3523725d78a2cb1c1528 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:51:54 -0400 Subject: [PATCH 05/13] feat: rename connect-client to connect-api and add unit tests Rename the package from @posit-dev/connect-client to @posit-dev/connect-api and add comprehensive unit tests for the ConnectClient class and error types. Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/package-lock.json | 1498 +++++++++++++++++ packages/connect-api/package.json | 16 + packages/connect-api/src/client.test.ts | 886 ++++++++++ .../src/client.ts | 2 +- packages/connect-api/src/errors.test.ts | 83 + .../src/errors.ts | 0 .../src/index.ts | 0 .../src/types.ts | 0 .../tsconfig.json | 0 packages/connect-api/vitest.config.ts | 7 + packages/connect-client/package-lock.json | 47 - packages/connect-client/package.json | 11 - test/connect-api-contracts/package-lock.json | 1212 ++++++------- test/connect-api-contracts/package.json | 2 +- .../src/clients/ts-direct-client.ts | 2 +- 15 files changed, 2978 insertions(+), 788 deletions(-) create mode 100644 packages/connect-api/package-lock.json create mode 100644 packages/connect-api/package.json create mode 100644 packages/connect-api/src/client.test.ts rename packages/{connect-client => connect-api}/src/client.ts (99%) create mode 100644 packages/connect-api/src/errors.test.ts rename packages/{connect-client => connect-api}/src/errors.ts (100%) rename packages/{connect-client => connect-api}/src/index.ts (100%) rename packages/{connect-client => connect-api}/src/types.ts (100%) rename packages/{connect-client => connect-api}/tsconfig.json (100%) create mode 100644 packages/connect-api/vitest.config.ts delete mode 100644 packages/connect-client/package-lock.json delete mode 100644 packages/connect-client/package.json diff --git a/packages/connect-api/package-lock.json b/packages/connect-api/package-lock.json new file mode 100644 index 000000000..a9b0cf42b --- /dev/null +++ b/packages/connect-api/package-lock.json @@ -0,0 +1,1498 @@ +{ + "name": "@posit-dev/connect-client", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@posit-dev/connect-client", + "version": "0.0.1", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^4.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/packages/connect-api/package.json b/packages/connect-api/package.json new file mode 100644 index 000000000..09a2bae7c --- /dev/null +++ b/packages/connect-api/package.json @@ -0,0 +1,16 @@ +{ + "name": "@posit-dev/connect-api", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "src/index.ts", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "@types/node": "^22.0.0", + "vitest": "^4.0.0" + } +} diff --git a/packages/connect-api/src/client.test.ts b/packages/connect-api/src/client.test.ts new file mode 100644 index 000000000..6c0631add --- /dev/null +++ b/packages/connect-api/src/client.test.ts @@ -0,0 +1,886 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConnectClient } from "./client.js"; +import { + AuthenticationError, + ConnectRequestError, + DeploymentValidationError, + TaskError, +} from "./errors.js"; +import type { + BundleID, + ContentID, + TaskID, + UserDTO, + ContentDetailsDTO, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const BASE_URL = "https://connect.example.com"; +const API_KEY = "test-api-key-123"; + +function createClient(): ConnectClient { + return new ConnectClient({ url: BASE_URL, apiKey: API_KEY }); +} + +function jsonResponse(body: unknown, status = 200, statusText = "OK"): Response { + return new Response(JSON.stringify(body), { + status, + statusText, + headers: { "Content-Type": "application/json" }, + }); +} + +function textResponse(body: string, status = 200, statusText = "OK"): Response { + return new Response(body, { status, statusText }); +} + +function binaryResponse(data: Uint8Array, status = 200): Response { + return new Response(data as unknown as BodyInit, { status, statusText: "OK" }); +} + +/** A valid publisher UserDTO for reuse across tests. */ +function validUserDTO(overrides?: Partial): UserDTO { + return { + guid: "user-guid-123", + username: "publisher1", + first_name: "Test", + last_name: "User", + email: "test@example.com", + user_role: "publisher", + created_time: "2024-01-01T00:00:00Z", + updated_time: "2024-01-01T00:00:00Z", + active_time: null, + confirmed: true, + locked: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Cross-cutting: Authorization header +// --------------------------------------------------------------------------- + +describe("Authorization header", () => { + it("sends Authorization: Key on every request", async () => { + const fetchSpy = vi.fn().mockResolvedValue(jsonResponse(validUserDTO())); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.getCurrentUser(); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [, init] = fetchSpy.mock.calls[0]; + expect(init.headers.Authorization).toBe(`Key ${API_KEY}`); + }); +}); + +// --------------------------------------------------------------------------- +// testAuthentication +// --------------------------------------------------------------------------- + +describe("testAuthentication", () => { + it("returns user with guid mapped to id on success", async () => { + const dto = validUserDTO(); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + + const client = createClient(); + const result = await client.testAuthentication(); + + expect(result.error).toBeNull(); + expect(result.user).toEqual({ + id: dto.guid, + username: dto.username, + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + }); + }); + + it("throws AuthenticationError on 401", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + jsonResponse({ error: "Unauthorized" }, 401, "Unauthorized"), + ); + + const client = createClient(); + await expect(client.testAuthentication()).rejects.toThrow( + AuthenticationError, + ); + }); + + it("throws AuthenticationError when user is locked", async () => { + const dto = validUserDTO({ locked: true }); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + + const client = createClient(); + await expect(client.testAuthentication()).rejects.toThrow( + /user account publisher1 is locked/, + ); + }); + + it("throws AuthenticationError when user is not confirmed", async () => { + const dto = validUserDTO({ confirmed: false }); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + + const client = createClient(); + await expect(client.testAuthentication()).rejects.toThrow( + /user account publisher1 is not confirmed/, + ); + }); + + it("throws AuthenticationError when user is a viewer", async () => { + const dto = validUserDTO({ user_role: "viewer" }); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + + const client = createClient(); + await expect(client.testAuthentication()).rejects.toThrow( + /does not have permission to publish content/, + ); + }); + + it("accepts administrator role", async () => { + const dto = validUserDTO({ user_role: "administrator" }); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + + const client = createClient(); + const result = await client.testAuthentication(); + expect(result.user).not.toBeNull(); + expect(result.error).toBeNull(); + }); + + it("throws AuthenticationError with generic message on non-JSON error body", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("not json", 403, "Forbidden")); + + const client = createClient(); + await expect(client.testAuthentication()).rejects.toThrow( + AuthenticationError, + ); + }); +}); + +// --------------------------------------------------------------------------- +// getCurrentUser +// --------------------------------------------------------------------------- + +describe("getCurrentUser", () => { + it("maps guid to id and returns User", async () => { + const dto = validUserDTO(); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + + const client = createClient(); + const user = await client.getCurrentUser(); + + expect(user).toEqual({ + id: dto.guid, + username: dto.username, + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + }); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("err", 500, "Internal Server Error")); + + const client = createClient(); + await expect(client.getCurrentUser()).rejects.toThrow(ConnectRequestError); + }); + + it("calls GET /__api__/v1/user", async () => { + const fetchSpy = vi.fn().mockResolvedValue(jsonResponse(validUserDTO())); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.getCurrentUser(); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/user`); + expect(init.method).toBe("GET"); + }); +}); + +// --------------------------------------------------------------------------- +// contentDetails +// --------------------------------------------------------------------------- + +describe("contentDetails", () => { + const contentId = "content-guid-abc" as ContentID; + const detailsDTO: ContentDetailsDTO = { + guid: contentId, + name: "my-app", + title: "My App", + description: "", + access_type: "acl", + connection_timeout: null, + read_timeout: null, + init_timeout: null, + idle_timeout: null, + max_processes: null, + min_processes: null, + max_conns_per_process: null, + load_factor: null, + created_time: "2024-01-01T00:00:00Z", + last_deployed_time: "2024-01-01T00:00:00Z", + bundle_id: "bundle-1", + app_mode: "python-dash", + content_category: "", + parameterized: false, + cluster_name: null, + image_name: null, + r_version: null, + py_version: "3.11.0", + quarto_version: null, + run_as: null, + run_as_current_user: false, + owner_guid: "owner-guid-123", + content_url: "https://connect.example.com/content/content-guid-abc/", + dashboard_url: "https://connect.example.com/connect/#/apps/content-guid-abc", + app_role: "owner", + id: "42", + }; + + it("returns the full content details DTO", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(detailsDTO)); + + const client = createClient(); + const result = await client.contentDetails(contentId); + + expect(result).toEqual(detailsDTO); + }); + + it("uses contentId in the URL path", async () => { + const fetchSpy = vi.fn().mockResolvedValue(jsonResponse(detailsDTO)); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.contentDetails(contentId); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}`); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("not found", 404, "Not Found")); + + const client = createClient(); + await expect(client.contentDetails(contentId)).rejects.toThrow( + ConnectRequestError, + ); + }); +}); + +// --------------------------------------------------------------------------- +// createDeployment +// --------------------------------------------------------------------------- + +describe("createDeployment", () => { + it("POSTs the body and returns the guid as ContentID", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(jsonResponse({ guid: "new-content-guid" })); + globalThis.fetch = fetchSpy; + + const client = createClient(); + const id = await client.createDeployment({ name: "my-app" }); + + expect(id).toBe("new-content-guid"); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/content`); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ name: "my-app" }); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("conflict", 409, "Conflict")); + + const client = createClient(); + await expect(client.createDeployment({ name: "dup" })).rejects.toThrow( + ConnectRequestError, + ); + }); +}); + +// --------------------------------------------------------------------------- +// updateDeployment +// --------------------------------------------------------------------------- + +describe("updateDeployment", () => { + const contentId = "content-123" as ContentID; + + it("PATCHes the content and returns void", async () => { + const fetchSpy = vi.fn().mockResolvedValue(jsonResponse({}, 200)); + globalThis.fetch = fetchSpy; + + const client = createClient(); + const result = await client.updateDeployment(contentId, { + title: "New Title", + }); + + expect(result).toBeUndefined(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}`); + expect(init.method).toBe("PATCH"); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("bad request", 400, "Bad Request")); + + const client = createClient(); + await expect( + client.updateDeployment(contentId, { title: "x" }), + ).rejects.toThrow(ConnectRequestError); + }); +}); + +// --------------------------------------------------------------------------- +// getEnvVars +// --------------------------------------------------------------------------- + +describe("getEnvVars", () => { + const contentId = "content-123" as ContentID; + + it("returns an array of environment variable names", async () => { + const envNames = ["DATABASE_URL", "SECRET_KEY"]; + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(envNames)); + + const client = createClient(); + const result = await client.getEnvVars(contentId); + + expect(result).toEqual(envNames); + }); + + it("calls the correct URL", async () => { + const fetchSpy = vi.fn().mockResolvedValue(jsonResponse([])); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.getEnvVars(contentId); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe( + `${BASE_URL}/__api__/v1/content/${contentId}/environment`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// setEnvVars +// --------------------------------------------------------------------------- + +describe("setEnvVars", () => { + const contentId = "content-123" as ContentID; + + it("converts Record to [{name,value}] array in the body", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204, statusText: "No Content" })); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.setEnvVars(contentId, { FOO: "bar", BAZ: "qux" }); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe( + `${BASE_URL}/__api__/v1/content/${contentId}/environment`, + ); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body)).toEqual([ + { name: "FOO", value: "bar" }, + { name: "BAZ", value: "qux" }, + ]); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("error", 500, "Internal Server Error")); + + const client = createClient(); + await expect( + client.setEnvVars(contentId, { KEY: "val" }), + ).rejects.toThrow(ConnectRequestError); + }); +}); + +// --------------------------------------------------------------------------- +// uploadBundle +// --------------------------------------------------------------------------- + +describe("uploadBundle", () => { + const contentId = "content-123" as ContentID; + + it("sends application/gzip with raw bytes and returns BundleID", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(jsonResponse({ id: "bundle-42" })); + globalThis.fetch = fetchSpy; + + const data = new Uint8Array([0x1f, 0x8b, 0x08]); + const client = createClient(); + const bundleId = await client.uploadBundle(contentId, data); + + expect(bundleId).toBe("bundle-42"); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}/bundles`); + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/gzip"); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("too large", 413, "Payload Too Large")); + + const client = createClient(); + await expect( + client.uploadBundle(contentId, new Uint8Array([1])), + ).rejects.toThrow(ConnectRequestError); + }); +}); + +// --------------------------------------------------------------------------- +// latestBundleId +// --------------------------------------------------------------------------- + +describe("latestBundleId", () => { + const contentId = "content-123" as ContentID; + + it("returns bundle_id from content details", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(jsonResponse({ bundle_id: "latest-bundle" })); + + const client = createClient(); + const bundleId = await client.latestBundleId(contentId); + + expect(bundleId).toBe("latest-bundle"); + }); + + it("calls GET on /__api__/v1/content/:id", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(jsonResponse({ bundle_id: "b1" })); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.latestBundleId(contentId); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}`); + expect(init.method).toBe("GET"); + }); +}); + +// --------------------------------------------------------------------------- +// downloadBundle +// --------------------------------------------------------------------------- + +describe("downloadBundle", () => { + const contentId = "content-123" as ContentID; + const bundleId = "bundle-42" as BundleID; + + it("returns a Uint8Array of the bundle data", async () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + globalThis.fetch = vi.fn().mockResolvedValue(binaryResponse(data)); + + const client = createClient(); + const result = await client.downloadBundle(contentId, bundleId); + + expect(result).toEqual(data); + }); + + it("calls the correct download URL", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(binaryResponse(new Uint8Array())); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.downloadBundle(contentId, bundleId); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe( + `${BASE_URL}/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + ); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("not found", 404, "Not Found")); + + const client = createClient(); + await expect( + client.downloadBundle(contentId, bundleId), + ).rejects.toThrow(ConnectRequestError); + }); +}); + +// --------------------------------------------------------------------------- +// deployBundle +// --------------------------------------------------------------------------- + +describe("deployBundle", () => { + const contentId = "content-123" as ContentID; + const bundleId = "bundle-42" as BundleID; + + it("POSTs {bundle_id} and returns task_id", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(jsonResponse({ task_id: "task-99" })); + globalThis.fetch = fetchSpy; + + const client = createClient(); + const taskId = await client.deployBundle(contentId, bundleId); + + expect(taskId).toBe("task-99"); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}/deploy`); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ bundle_id: bundleId }); + }); + + it("throws ConnectRequestError on non-2xx", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("err", 500, "Internal Server Error")); + + const client = createClient(); + await expect( + client.deployBundle(contentId, bundleId), + ).rejects.toThrow(ConnectRequestError); + }); +}); + +// --------------------------------------------------------------------------- +// waitForTask +// --------------------------------------------------------------------------- + +describe("waitForTask", () => { + const taskId = "task-99" as TaskID; + + it("returns {finished: true} when task completes without error", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + jsonResponse({ + id: taskId, + output: ["line 1"], + result: null, + finished: true, + code: 0, + error: "", + last: 1, + }), + ); + + const client = createClient(); + const result = await client.waitForTask(taskId, 0); + + expect(result).toEqual({ finished: true }); + }); + + it("throws TaskError when task finishes with an error", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + jsonResponse({ + id: taskId, + output: [], + result: null, + finished: true, + code: 1, + error: "deployment failed", + last: 0, + }), + ); + + const client = createClient(); + const err = await client + .waitForTask(taskId, 0) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(TaskError); + expect((err as TaskError).message).toMatch(/deployment failed/); + expect((err as TaskError).code).toBe(1); + }); + + it("polls with first= query parameter and follows last cursor", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: taskId, + output: ["line 1"], + result: null, + finished: false, + code: 0, + error: "", + last: 3, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + id: taskId, + output: ["line 2"], + result: null, + finished: true, + code: 0, + error: "", + last: 5, + }), + ); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.waitForTask(taskId, 0); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [url1] = fetchSpy.mock.calls[0]; + const [url2] = fetchSpy.mock.calls[1]; + expect(url1).toBe(`${BASE_URL}/__api__/v1/tasks/${taskId}?first=0`); + expect(url2).toBe(`${BASE_URL}/__api__/v1/tasks/${taskId}?first=3`); + }); +}); + +// --------------------------------------------------------------------------- +// validateDeployment +// --------------------------------------------------------------------------- + +describe("validateDeployment", () => { + const contentId = "content-123" as ContentID; + + it("returns void on 200", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(textResponse("OK", 200)); + + const client = createClient(); + const result = await client.validateDeployment(contentId); + + expect(result).toBeUndefined(); + }); + + it("returns void on 404 (acceptable)", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("not found", 404, "Not Found")); + + const client = createClient(); + const result = await client.validateDeployment(contentId); + + expect(result).toBeUndefined(); + }); + + it("throws DeploymentValidationError on 500", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + textResponse("server error", 500, "Internal Server Error"), + ); + + const client = createClient(); + await expect(client.validateDeployment(contentId)).rejects.toThrow( + DeploymentValidationError, + ); + }); + + it("throws DeploymentValidationError on 502", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(textResponse("bad gateway", 502, "Bad Gateway")); + + const client = createClient(); + await expect(client.validateDeployment(contentId)).rejects.toThrow( + DeploymentValidationError, + ); + }); + + it("calls GET /content/:id/", async () => { + const fetchSpy = vi.fn().mockResolvedValue(textResponse("OK", 200)); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.validateDeployment(contentId); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/content/${contentId}/`); + }); +}); + +// --------------------------------------------------------------------------- +// getIntegrations +// --------------------------------------------------------------------------- + +describe("getIntegrations", () => { + it("returns parsed integration array", async () => { + const integrations = [ + { + guid: "int-1", + name: "GitHub", + description: "GitHub integration", + auth_type: "oauth2", + template: "github", + config: {}, + created_time: "2024-01-01T00:00:00Z", + }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(integrations)); + + const client = createClient(); + const result = await client.getIntegrations(); + + expect(result).toEqual(integrations); + }); + + it("calls GET /__api__/v1/oauth/integrations", async () => { + const fetchSpy = vi.fn().mockResolvedValue(jsonResponse([])); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.getIntegrations(); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/__api__/v1/oauth/integrations`); + }); +}); + +// --------------------------------------------------------------------------- +// getSettings +// --------------------------------------------------------------------------- + +describe("getSettings", () => { + const userDTO = validUserDTO(); + const general = { + license: { + "allow-apis": true, + "current-user-execution": false, + "enable-launcher": false, + "oauth-integrations": false, + }, + runtimes: ["python"], + git_enabled: false, + git_available: false, + execution_type: "native", + enable_runtime_constraints: false, + enable_image_management: false, + default_image_selection_enabled: false, + default_environment_management_selection: true, + default_r_environment_management: true, + default_py_environment_management: true, + oauth_integrations_enabled: false, + }; + const application = { + access_types: ["acl", "logged_in", "all"], + run_as: "", + run_as_group: "", + run_as_current_user: false, + }; + const scheduler = { + min_processes: 0, + max_processes: 3, + max_conns_per_process: 20, + load_factor: 0.5, + init_timeout: 60, + idle_timeout: 120, + min_processes_limit: 0, + max_processes_limit: 10, + connection_timeout: 5, + read_timeout: 30, + cpu_request: 0, + max_cpu_request: 0, + cpu_limit: 0, + max_cpu_limit: 0, + memory_request: 0, + max_memory_request: 0, + memory_limit: 0, + max_memory_limit: 0, + amd_gpu_limit: 0, + max_amd_gpu_limit: 0, + nvidia_gpu_limit: 0, + max_nvidia_gpu_limit: 0, + }; + const python = { + installations: [{ version: "3.11.0", cluster_name: "", image_name: "" }], + api_enabled: true, + }; + const r = { + installations: [{ version: "4.3.0", cluster_name: "", image_name: "" }], + }; + const quarto = { + installations: [{ version: "1.4.0", cluster_name: "", image_name: "" }], + }; + + it("makes 7 fetch calls to the correct URLs", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(jsonResponse(userDTO)) + .mockResolvedValueOnce(jsonResponse(general)) + .mockResolvedValueOnce(jsonResponse(application)) + .mockResolvedValueOnce(jsonResponse(scheduler)) + .mockResolvedValueOnce(jsonResponse(python)) + .mockResolvedValueOnce(jsonResponse(r)) + .mockResolvedValueOnce(jsonResponse(quarto)); + globalThis.fetch = fetchSpy; + + const client = createClient(); + await client.getSettings(); + + expect(fetchSpy).toHaveBeenCalledTimes(7); + + const urls = fetchSpy.mock.calls.map( + (call: unknown[]) => call[0] as string, + ); + expect(urls).toEqual([ + `${BASE_URL}/__api__/v1/user`, + `${BASE_URL}/__api__/server_settings`, + `${BASE_URL}/__api__/server_settings/applications`, + `${BASE_URL}/__api__/server_settings/scheduler`, + `${BASE_URL}/__api__/v1/server_settings/python`, + `${BASE_URL}/__api__/v1/server_settings/r`, + `${BASE_URL}/__api__/v1/server_settings/quarto`, + ]); + }); + + it("returns an AllSettings composite object", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse(userDTO)) + .mockResolvedValueOnce(jsonResponse(general)) + .mockResolvedValueOnce(jsonResponse(application)) + .mockResolvedValueOnce(jsonResponse(scheduler)) + .mockResolvedValueOnce(jsonResponse(python)) + .mockResolvedValueOnce(jsonResponse(r)) + .mockResolvedValueOnce(jsonResponse(quarto)); + globalThis.fetch = globalThis.fetch; + + const client = createClient(); + const settings = await client.getSettings(); + + expect(settings.General).toEqual(general); + expect(settings.user).toEqual(userDTO); + expect(settings.application).toEqual(application); + expect(settings.scheduler).toEqual(scheduler); + expect(settings.python).toEqual(python); + expect(settings.r).toEqual(r); + expect(settings.quarto).toEqual(quarto); + }); +}); diff --git a/packages/connect-client/src/client.ts b/packages/connect-api/src/client.ts similarity index 99% rename from packages/connect-client/src/client.ts rename to packages/connect-api/src/client.ts index 1c0da447b..403219db1 100644 --- a/packages/connect-client/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -93,7 +93,7 @@ export class ConnectClient { /** * Validates credentials and checks user state (locked, confirmed, role). - * Returns `{ user, error }` instead of throwing — matches Go harness contract. + * Returns `{ user, error: null }` on success; throws AuthenticationError otherwise. */ async testAuthentication(): Promise<{ user: User | null; diff --git a/packages/connect-api/src/errors.test.ts b/packages/connect-api/src/errors.test.ts new file mode 100644 index 000000000..1dbb5ba7b --- /dev/null +++ b/packages/connect-api/src/errors.test.ts @@ -0,0 +1,83 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import { + AuthenticationError, + ConnectClientError, + ConnectRequestError, + DeploymentValidationError, + TaskError, +} from "./errors.js"; + +describe("ConnectClientError", () => { + it("is an instance of Error", () => { + const err = new ConnectClientError("base error"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("ConnectClientError"); + expect(err.message).toBe("base error"); + }); +}); + +describe("ConnectRequestError", () => { + it("extends ConnectClientError", () => { + const err = new ConnectRequestError(404, "Not Found", "page missing"); + expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(Error); + }); + + it("carries status, statusText, and body", () => { + const err = new ConnectRequestError(500, "Internal Server Error", "oops"); + expect(err.status).toBe(500); + expect(err.statusText).toBe("Internal Server Error"); + expect(err.body).toBe("oops"); + expect(err.name).toBe("ConnectRequestError"); + expect(err.message).toBe("HTTP 500: Internal Server Error"); + }); +}); + +describe("AuthenticationError", () => { + it("extends ConnectClientError", () => { + const err = new AuthenticationError("locked"); + expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(Error); + }); + + it("has the correct name and message", () => { + const err = new AuthenticationError("account locked"); + expect(err.name).toBe("AuthenticationError"); + expect(err.message).toBe("account locked"); + }); +}); + +describe("TaskError", () => { + it("extends ConnectClientError", () => { + const err = new TaskError("task-1", "failed to build", 1); + expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(Error); + }); + + it("carries taskId, errorMessage, and code", () => { + const err = new TaskError("task-abc", "timeout", 137); + expect(err.taskId).toBe("task-abc"); + expect(err.errorMessage).toBe("timeout"); + expect(err.code).toBe(137); + expect(err.name).toBe("TaskError"); + expect(err.message).toBe("timeout"); + }); +}); + +describe("DeploymentValidationError", () => { + it("extends ConnectClientError", () => { + const err = new DeploymentValidationError("content-1", 502); + expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(Error); + }); + + it("carries contentId and httpStatus", () => { + const err = new DeploymentValidationError("content-xyz", 500); + expect(err.contentId).toBe("content-xyz"); + expect(err.httpStatus).toBe(500); + expect(err.name).toBe("DeploymentValidationError"); + expect(err.message).toBe("deployed content does not seem to be running"); + }); +}); diff --git a/packages/connect-client/src/errors.ts b/packages/connect-api/src/errors.ts similarity index 100% rename from packages/connect-client/src/errors.ts rename to packages/connect-api/src/errors.ts diff --git a/packages/connect-client/src/index.ts b/packages/connect-api/src/index.ts similarity index 100% rename from packages/connect-client/src/index.ts rename to packages/connect-api/src/index.ts diff --git a/packages/connect-client/src/types.ts b/packages/connect-api/src/types.ts similarity index 100% rename from packages/connect-client/src/types.ts rename to packages/connect-api/src/types.ts diff --git a/packages/connect-client/tsconfig.json b/packages/connect-api/tsconfig.json similarity index 100% rename from packages/connect-client/tsconfig.json rename to packages/connect-api/tsconfig.json diff --git a/packages/connect-api/vitest.config.ts b/packages/connect-api/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/connect-api/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/connect-client/package-lock.json b/packages/connect-client/package-lock.json deleted file mode 100644 index 782b1bf6b..000000000 --- a/packages/connect-client/package-lock.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@posit-dev/connect-client", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@posit-dev/connect-client", - "version": "0.0.1", - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.7.0" - } - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/packages/connect-client/package.json b/packages/connect-client/package.json deleted file mode 100644 index 31e1cfd4d..000000000 --- a/packages/connect-client/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@posit-dev/connect-client", - "private": true, - "version": "0.0.1", - "type": "module", - "main": "src/index.ts", - "devDependencies": { - "typescript": "^5.7.0", - "@types/node": "^22.0.0" - } -} diff --git a/test/connect-api-contracts/package-lock.json b/test/connect-api-contracts/package-lock.json index 94e517262..ce20d364d 100644 --- a/test/connect-api-contracts/package-lock.json +++ b/test/connect-api-contracts/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "connect-api-contracts", "dependencies": { - "@posit-dev/connect-client": "file:../../packages/connect-client" + "@posit-dev/connect-api": "file:../../packages/connect-api" }, "devDependencies": { "@types/node": "^22.0.0", @@ -16,52 +16,37 @@ "vitest": "^4.0.0" } }, - "../../packages/connect-client": { - "name": "@posit-dev/connect-client", + "../../packages/connect-api": { + "name": "@posit-dev/connect-api", "version": "0.0.1", "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^4.0.0" } }, - "node_modules/@esbuild/aix-ppc64": { + "../../packages/connect-api/node_modules/@esbuild/darwin-arm64": { "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "aix" + "darwin" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], + "../../packages/connect-api/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "../../packages/connect-api/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", "cpu": [ "arm64" ], @@ -69,775 +54,639 @@ "license": "MIT", "optional": true, "os": [ - "android" - ], - "engines": { - "node": ">=18" - } + "darwin" + ] }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/@standard-schema/spec": { + "version": "1.1.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/@types/chai": { + "version": "5.2.3", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/@types/deep-eql": { + "version": "4.0.2", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/@types/estree": { + "version": "1.0.8", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/@types/node": { + "version": "22.19.15", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "undici-types": "~6.21.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], + "../../packages/connect-api/node_modules/@vitest/expect": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/@vitest/mocker": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], + "../../packages/connect-api/node_modules/@vitest/pretty-format": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], + "../../packages/connect-api/node_modules/@vitest/runner": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], + "../../packages/connect-api/node_modules/@vitest/snapshot": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], + "../../packages/connect-api/node_modules/@vitest/spy": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], + "../../packages/connect-api/node_modules/@vitest/utils": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], + "../../packages/connect-api/node_modules/assertion-error": { + "version": "2.0.1", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/chai": { + "version": "6.2.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/es-module-lexer": { + "version": "1.7.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/@esbuild/netbsd-x64": { + "../../packages/connect-api/node_modules/esbuild": { "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], "dev": true, + "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/estree-walker": { + "version": "3.0.3", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/expect-type": { + "version": "1.3.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.0.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/fdir": { + "version": "6.5.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/magic-string": { + "version": "0.30.21", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], + "../../packages/connect-api/node_modules/nanoid": { + "version": "3.3.11", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@posit-dev/connect-client": { - "resolved": "../../packages/connect-client", - "link": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/obug": { + "version": "2.1.1", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/pathe": { + "version": "2.0.3", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/picocolors": { + "version": "1.1.1", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "license": "ISC" }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/picomatch": { + "version": "4.0.3", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], + "../../packages/connect-api/node_modules/postcss": { + "version": "8.5.8", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { + "../../packages/connect-api/node_modules/rollup": { "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/siginfo": { + "version": "2.0.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "ISC" }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], + "../../packages/connect-api/node_modules/source-map-js": { + "version": "1.2.1", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], + "../../packages/connect-api/node_modules/stackback": { + "version": "0.0.2", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], + "../../packages/connect-api/node_modules/std-env": { + "version": "3.10.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], + "../../packages/connect-api/node_modules/tinybench": { + "version": "2.9.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], + "../../packages/connect-api/node_modules/tinyexec": { + "version": "1.0.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], + "../../packages/connect-api/node_modules/tinyglobby": { + "version": "0.2.15", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], + "../../packages/connect-api/node_modules/tinyrainbow": { + "version": "3.0.3", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/typescript": { + "version": "5.9.3", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/undici-types": { + "version": "6.21.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], + "../../packages/connect-api/node_modules/vite": { + "version": "7.3.1", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/vitest": { + "version": "4.0.18", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], + "../../packages/connect-api/node_modules/why-is-node-running": { + "version": "2.3.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" + "darwin" ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" + }, + "node_modules/@posit-dev/connect-api": { + "resolved": "../../packages/connect-api", + "link": true }, - "node_modules/@rollup/rollup-win32-x64-msvc": { + "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ] }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -847,22 +696,16 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", "dependencies": { @@ -871,8 +714,6 @@ }, "node_modules/@vitest/expect": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -889,8 +730,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -916,8 +755,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { @@ -929,8 +766,6 @@ }, "node_modules/@vitest/runner": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { @@ -943,8 +778,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { @@ -958,8 +791,6 @@ }, "node_modules/@vitest/spy": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", "funding": { @@ -968,8 +799,6 @@ }, "node_modules/@vitest/utils": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { @@ -982,8 +811,6 @@ }, "node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -999,8 +826,6 @@ }, "node_modules/ajv-formats": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1017,8 +842,6 @@ }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -1027,8 +850,6 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -1037,15 +858,11 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1086,8 +903,6 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -1096,8 +911,6 @@ }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1106,15 +919,11 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -1130,8 +939,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -1148,10 +955,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1163,15 +967,11 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1180,8 +980,6 @@ }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -1199,8 +997,6 @@ }, "node_modules/obug": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -1210,22 +1006,16 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -1237,8 +1027,6 @@ }, "node_modules/postcss": { "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -1266,8 +1054,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { @@ -1276,8 +1062,6 @@ }, "node_modules/rollup": { "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1321,15 +1105,11 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1338,29 +1118,21 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, "license": "MIT", "engines": { @@ -1369,8 +1141,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1386,8 +1156,6 @@ }, "node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -1396,8 +1164,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1410,15 +1176,11 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "node_modules/vite": { "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { @@ -1492,8 +1254,6 @@ }, "node_modules/vitest": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1570,8 +1330,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/test/connect-api-contracts/package.json b/test/connect-api-contracts/package.json index 56e76485b..86c3d1f07 100644 --- a/test/connect-api-contracts/package.json +++ b/test/connect-api-contracts/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@posit-dev/connect-client": "file:../../packages/connect-client" + "@posit-dev/connect-api": "file:../../packages/connect-api" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts index bcf5c49d3..81163e065 100644 --- a/test/connect-api-contracts/src/clients/ts-direct-client.ts +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -5,7 +5,7 @@ import { type ContentID, type BundleID, type TaskID, -} from "@posit-dev/connect-client"; +} from "@posit-dev/connect-api"; import type { ConnectContractClient, From fbf75d2481ebb8e19cbfe428abb162c847b27862 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:54:20 -0400 Subject: [PATCH 06/13] refactor: rename ConnectClient class to ConnectAPI Also renames ConnectClientOptions to ConnectAPIOptions and ConnectClientError to ConnectAPIError for consistency. Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/src/client.test.ts | 6 ++--- packages/connect-api/src/client.ts | 6 ++--- packages/connect-api/src/errors.test.ts | 24 +++++++++---------- packages/connect-api/src/errors.ts | 12 +++++----- packages/connect-api/src/index.ts | 6 ++--- packages/connect-api/src/types.ts | 2 +- .../src/clients/ts-direct-client.ts | 8 +++---- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/connect-api/src/client.test.ts b/packages/connect-api/src/client.test.ts index 6c0631add..8354a769e 100644 --- a/packages/connect-api/src/client.test.ts +++ b/packages/connect-api/src/client.test.ts @@ -1,7 +1,7 @@ // Copyright (C) 2026 by Posit Software, PBC. import { afterEach, describe, expect, it, vi } from "vitest"; -import { ConnectClient } from "./client.js"; +import { ConnectAPI } from "./client.js"; import { AuthenticationError, ConnectRequestError, @@ -23,8 +23,8 @@ import type { const BASE_URL = "https://connect.example.com"; const API_KEY = "test-api-key-123"; -function createClient(): ConnectClient { - return new ConnectClient({ url: BASE_URL, apiKey: API_KEY }); +function createClient(): ConnectAPI { + return new ConnectAPI({ url: BASE_URL, apiKey: API_KEY }); } function jsonResponse(body: unknown, status = 200, statusText = "OK"): Response { diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index 403219db1..5d9184b8a 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -4,7 +4,7 @@ import type { AllSettings, ApplicationSettings, BundleID, - ConnectClientOptions, + ConnectAPIOptions, ConnectContent, ContentDetailsDTO, ContentID, @@ -34,11 +34,11 @@ import { * Uses native fetch with zero runtime dependencies. * Property names use snake_case to match the Connect API JSON wire format. */ -export class ConnectClient { +export class ConnectAPI { private readonly url: string; private readonly apiKey: string; - constructor(options: ConnectClientOptions) { + constructor(options: ConnectAPIOptions) { this.url = options.url; this.apiKey = options.apiKey; } diff --git a/packages/connect-api/src/errors.test.ts b/packages/connect-api/src/errors.test.ts index 1dbb5ba7b..9dbce5fdc 100644 --- a/packages/connect-api/src/errors.test.ts +++ b/packages/connect-api/src/errors.test.ts @@ -3,25 +3,25 @@ import { describe, expect, it } from "vitest"; import { AuthenticationError, - ConnectClientError, + ConnectAPIError, ConnectRequestError, DeploymentValidationError, TaskError, } from "./errors.js"; -describe("ConnectClientError", () => { +describe("ConnectAPIError", () => { it("is an instance of Error", () => { - const err = new ConnectClientError("base error"); + const err = new ConnectAPIError("base error"); expect(err).toBeInstanceOf(Error); - expect(err.name).toBe("ConnectClientError"); + expect(err.name).toBe("ConnectAPIError"); expect(err.message).toBe("base error"); }); }); describe("ConnectRequestError", () => { - it("extends ConnectClientError", () => { + it("extends ConnectAPIError", () => { const err = new ConnectRequestError(404, "Not Found", "page missing"); - expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(ConnectAPIError); expect(err).toBeInstanceOf(Error); }); @@ -36,9 +36,9 @@ describe("ConnectRequestError", () => { }); describe("AuthenticationError", () => { - it("extends ConnectClientError", () => { + it("extends ConnectAPIError", () => { const err = new AuthenticationError("locked"); - expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(ConnectAPIError); expect(err).toBeInstanceOf(Error); }); @@ -50,9 +50,9 @@ describe("AuthenticationError", () => { }); describe("TaskError", () => { - it("extends ConnectClientError", () => { + it("extends ConnectAPIError", () => { const err = new TaskError("task-1", "failed to build", 1); - expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(ConnectAPIError); expect(err).toBeInstanceOf(Error); }); @@ -67,9 +67,9 @@ describe("TaskError", () => { }); describe("DeploymentValidationError", () => { - it("extends ConnectClientError", () => { + it("extends ConnectAPIError", () => { const err = new DeploymentValidationError("content-1", 502); - expect(err).toBeInstanceOf(ConnectClientError); + expect(err).toBeInstanceOf(ConnectAPIError); expect(err).toBeInstanceOf(Error); }); diff --git a/packages/connect-api/src/errors.ts b/packages/connect-api/src/errors.ts index ccfb64ab0..b83d2d134 100644 --- a/packages/connect-api/src/errors.ts +++ b/packages/connect-api/src/errors.ts @@ -1,15 +1,15 @@ // Copyright (C) 2026 by Posit Software, PBC. /** Base error class for all Connect client errors. */ -export class ConnectClientError extends Error { +export class ConnectAPIError extends Error { constructor(message: string) { super(message); - this.name = "ConnectClientError"; + this.name = "ConnectAPIError"; } } /** HTTP request returned a non-2xx status. */ -export class ConnectRequestError extends ConnectClientError { +export class ConnectRequestError extends ConnectAPIError { constructor( public readonly status: number, public readonly statusText: string, @@ -21,7 +21,7 @@ export class ConnectRequestError extends ConnectClientError { } /** User account is locked, unconfirmed, or has insufficient role. */ -export class AuthenticationError extends ConnectClientError { +export class AuthenticationError extends ConnectAPIError { constructor(message: string) { super(message); this.name = "AuthenticationError"; @@ -29,7 +29,7 @@ export class AuthenticationError extends ConnectClientError { } /** Task finished with a non-zero exit code or error message. */ -export class TaskError extends ConnectClientError { +export class TaskError extends ConnectAPIError { constructor( public readonly taskId: string, public readonly errorMessage: string, @@ -41,7 +41,7 @@ export class TaskError extends ConnectClientError { } /** Deployed content URL returned a 5xx status. */ -export class DeploymentValidationError extends ConnectClientError { +export class DeploymentValidationError extends ConnectAPIError { constructor( public readonly contentId: string, public readonly httpStatus: number, diff --git a/packages/connect-api/src/index.ts b/packages/connect-api/src/index.ts index 6665af473..e7b84cd5f 100644 --- a/packages/connect-api/src/index.ts +++ b/packages/connect-api/src/index.ts @@ -1,13 +1,13 @@ // Copyright (C) 2026 by Posit Software, PBC. -export { ConnectClient } from "./client.js"; +export { ConnectAPI} from "./client.js"; export type { AllSettings, ApplicationSettings, BundleDTO, BundleID, - ConnectClientOptions, + ConnectAPIOptions, ConnectContent, ContentDetailsDTO, ContentID, @@ -33,7 +33,7 @@ export type { export { AuthenticationError, - ConnectClientError, + ConnectAPIError, ConnectRequestError, DeploymentValidationError, TaskError, diff --git a/packages/connect-api/src/types.ts b/packages/connect-api/src/types.ts index d78391632..69022f3c4 100644 --- a/packages/connect-api/src/types.ts +++ b/packages/connect-api/src/types.ts @@ -14,7 +14,7 @@ export type GUID = string & { readonly __brand: "GUID" }; // Client options // --------------------------------------------------------------------------- -export interface ConnectClientOptions { +export interface ConnectAPIOptions { url: string; apiKey: string; } diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts index 81163e065..4510f6870 100644 --- a/test/connect-api-contracts/src/clients/ts-direct-client.ts +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -1,7 +1,7 @@ // Copyright (C) 2026 by Posit Software, PBC. import { - ConnectClient, + ConnectAPI, type ContentID, type BundleID, type TaskID, @@ -17,18 +17,18 @@ import { Method } from "../client"; import type { CapturedRequest } from "../mock-connect-server"; /** - * Thin adapter that wraps the production ConnectClient for contract testing. + * Thin adapter that wraps the production ConnectAPI for contract testing. * Handles mock server request capture and maps return values to the * contract result shapes ({ contentId }, { bundleId }, etc.). */ export class TypeScriptDirectClient implements ConnectContractClient { - private readonly connectClient: ConnectClient; + private readonly connectClient: ConnectAPI; constructor( private connectUrl: string, apiKey: string, ) { - this.connectClient = new ConnectClient({ url: connectUrl, apiKey }); + this.connectClient = new ConnectAPI({ url: connectUrl, apiKey }); } async call( From 6152aafad224ad9e9379b8c6555f0a57a15871fe Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:27:09 -0400 Subject: [PATCH 07/13] refactor: migrate ConnectAPI from native fetch to axios Replace native fetch with axios for HTTP requests in the ConnectAPI class. Uses axios.create() with baseURL, default Authorization header, and validateStatus: () => true to preserve existing error handling. Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/package-lock.json | 286 +++++++++++++++++- packages/connect-api/package.json | 3 + packages/connect-api/src/client.test.ts | 370 +++++++++++++----------- packages/connect-api/src/client.ts | 92 +++--- 4 files changed, 543 insertions(+), 208 deletions(-) diff --git a/packages/connect-api/package-lock.json b/packages/connect-api/package-lock.json index a9b0cf42b..3463dffd2 100644 --- a/packages/connect-api/package-lock.json +++ b/packages/connect-api/package-lock.json @@ -1,12 +1,15 @@ { - "name": "@posit-dev/connect-client", + "name": "@posit-dev/connect-api", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@posit-dev/connect-client", + "name": "@posit-dev/connect-api", "version": "0.0.1", + "dependencies": { + "axios": "^1.9.0" + }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0", @@ -975,6 +978,36 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -985,6 +1018,59 @@ "node": ">=18" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -992,6 +1078,33 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1072,6 +1185,42 @@ } } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1087,6 +1236,103 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1097,6 +1343,36 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1183,6 +1459,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", diff --git a/packages/connect-api/package.json b/packages/connect-api/package.json index 09a2bae7c..a2d19fac5 100644 --- a/packages/connect-api/package.json +++ b/packages/connect-api/package.json @@ -8,6 +8,9 @@ "test": "vitest run", "test:watch": "vitest" }, + "dependencies": { + "axios": "^1.9.0" + }, "devDependencies": { "typescript": "^5.7.0", "@types/node": "^22.0.0", diff --git a/packages/connect-api/src/client.test.ts b/packages/connect-api/src/client.test.ts index 8354a769e..4f4379af9 100644 --- a/packages/connect-api/src/client.test.ts +++ b/packages/connect-api/src/client.test.ts @@ -1,6 +1,7 @@ // Copyright (C) 2026 by Posit Software, PBC. import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; import { ConnectAPI } from "./client.js"; import { AuthenticationError, @@ -16,6 +17,25 @@ import type { ContentDetailsDTO, } from "./types.js"; +// --------------------------------------------------------------------------- +// Mock axios +// --------------------------------------------------------------------------- + +const mockRequest = vi.fn(); + +vi.mock("axios", () => { + return { + default: { + create: vi.fn(() => ({ + request: mockRequest, + })), + }, + }; +}); + +// Re-import axios so we can inspect axios.create calls +import axios from "axios"; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -27,20 +47,50 @@ function createClient(): ConnectAPI { return new ConnectAPI({ url: BASE_URL, apiKey: API_KEY }); } -function jsonResponse(body: unknown, status = 200, statusText = "OK"): Response { - return new Response(JSON.stringify(body), { +function jsonResponse( + body: unknown, + status = 200, + statusText = "OK", +): { status: number; statusText: string; data: unknown; headers: Record; config: object } { + return { status, statusText, - headers: { "Content-Type": "application/json" }, - }); + data: body, + headers: { "content-type": "application/json" }, + config: {}, + }; } -function textResponse(body: string, status = 200, statusText = "OK"): Response { - return new Response(body, { status, statusText }); +function textResponse( + body: string, + status = 200, + statusText = "OK", +): { status: number; statusText: string; data: string; headers: Record; config: object } { + return { + status, + statusText, + data: body, + headers: {}, + config: {}, + }; } -function binaryResponse(data: Uint8Array, status = 200): Response { - return new Response(data as unknown as BodyInit, { status, statusText: "OK" }); +function binaryResponse( + data: Uint8Array, + status = 200, +): { status: number; statusText: string; data: ArrayBuffer; headers: Record; config: object } { + // Convert Uint8Array to ArrayBuffer (axios returns ArrayBuffer with responseType: "arraybuffer") + const buffer = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + return { + status, + statusText: "OK", + data: buffer, + headers: {}, + config: {}, + }; } /** A valid publisher UserDTO for reuse across tests. */ @@ -65,11 +115,9 @@ function validUserDTO(overrides?: Partial): UserDTO { // Setup / teardown // --------------------------------------------------------------------------- -const originalFetch = globalThis.fetch; - afterEach(() => { - globalThis.fetch = originalFetch; vi.restoreAllMocks(); + mockRequest.mockReset(); }); // --------------------------------------------------------------------------- @@ -78,15 +126,19 @@ afterEach(() => { describe("Authorization header", () => { it("sends Authorization: Key on every request", async () => { - const fetchSpy = vi.fn().mockResolvedValue(jsonResponse(validUserDTO())); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse(validUserDTO())); const client = createClient(); await client.getCurrentUser(); - expect(fetchSpy).toHaveBeenCalledOnce(); - const [, init] = fetchSpy.mock.calls[0]; - expect(init.headers.Authorization).toBe(`Key ${API_KEY}`); + expect(axios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: BASE_URL, + headers: expect.objectContaining({ + Authorization: `Key ${API_KEY}`, + }), + }), + ); }); }); @@ -97,7 +149,7 @@ describe("Authorization header", () => { describe("testAuthentication", () => { it("returns user with guid mapped to id on success", async () => { const dto = validUserDTO(); - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); const result = await client.testAuthentication(); @@ -113,11 +165,9 @@ describe("testAuthentication", () => { }); it("throws AuthenticationError on 401", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue( - jsonResponse({ error: "Unauthorized" }, 401, "Unauthorized"), - ); + mockRequest.mockResolvedValue( + jsonResponse({ error: "Unauthorized" }, 401, "Unauthorized"), + ); const client = createClient(); await expect(client.testAuthentication()).rejects.toThrow( @@ -127,7 +177,7 @@ describe("testAuthentication", () => { it("throws AuthenticationError when user is locked", async () => { const dto = validUserDTO({ locked: true }); - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); await expect(client.testAuthentication()).rejects.toThrow( @@ -137,7 +187,7 @@ describe("testAuthentication", () => { it("throws AuthenticationError when user is not confirmed", async () => { const dto = validUserDTO({ confirmed: false }); - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); await expect(client.testAuthentication()).rejects.toThrow( @@ -147,7 +197,7 @@ describe("testAuthentication", () => { it("throws AuthenticationError when user is a viewer", async () => { const dto = validUserDTO({ user_role: "viewer" }); - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); await expect(client.testAuthentication()).rejects.toThrow( @@ -157,7 +207,7 @@ describe("testAuthentication", () => { it("accepts administrator role", async () => { const dto = validUserDTO({ user_role: "administrator" }); - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); const result = await client.testAuthentication(); @@ -166,9 +216,7 @@ describe("testAuthentication", () => { }); it("throws AuthenticationError with generic message on non-JSON error body", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("not json", 403, "Forbidden")); + mockRequest.mockResolvedValue(textResponse("not json", 403, "Forbidden")); const client = createClient(); await expect(client.testAuthentication()).rejects.toThrow( @@ -184,7 +232,7 @@ describe("testAuthentication", () => { describe("getCurrentUser", () => { it("maps guid to id and returns User", async () => { const dto = validUserDTO(); - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(dto)); + mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); const user = await client.getCurrentUser(); @@ -199,24 +247,24 @@ describe("getCurrentUser", () => { }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("err", 500, "Internal Server Error")); + mockRequest.mockResolvedValue( + textResponse("err", 500, "Internal Server Error"), + ); const client = createClient(); await expect(client.getCurrentUser()).rejects.toThrow(ConnectRequestError); }); it("calls GET /__api__/v1/user", async () => { - const fetchSpy = vi.fn().mockResolvedValue(jsonResponse(validUserDTO())); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse(validUserDTO())); const client = createClient(); await client.getCurrentUser(); - const [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/user`); - expect(init.method).toBe("GET"); + expect(mockRequest).toHaveBeenCalledOnce(); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe("/__api__/v1/user"); + expect(call.method).toBe("GET"); }); }); @@ -255,13 +303,14 @@ describe("contentDetails", () => { run_as_current_user: false, owner_guid: "owner-guid-123", content_url: "https://connect.example.com/content/content-guid-abc/", - dashboard_url: "https://connect.example.com/connect/#/apps/content-guid-abc", + dashboard_url: + "https://connect.example.com/connect/#/apps/content-guid-abc", app_role: "owner", id: "42", }; it("returns the full content details DTO", async () => { - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(detailsDTO)); + mockRequest.mockResolvedValue(jsonResponse(detailsDTO)); const client = createClient(); const result = await client.contentDetails(contentId); @@ -270,20 +319,19 @@ describe("contentDetails", () => { }); it("uses contentId in the URL path", async () => { - const fetchSpy = vi.fn().mockResolvedValue(jsonResponse(detailsDTO)); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse(detailsDTO)); const client = createClient(); await client.contentDetails(contentId); - const [url] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}`); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe(`/__api__/v1/content/${contentId}`); }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("not found", 404, "Not Found")); + mockRequest.mockResolvedValue( + textResponse("not found", 404, "Not Found"), + ); const client = createClient(); await expect(client.contentDetails(contentId)).rejects.toThrow( @@ -298,25 +346,24 @@ describe("contentDetails", () => { describe("createDeployment", () => { it("POSTs the body and returns the guid as ContentID", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValue(jsonResponse({ guid: "new-content-guid" })); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue( + jsonResponse({ guid: "new-content-guid" }), + ); const client = createClient(); const id = await client.createDeployment({ name: "my-app" }); expect(id).toBe("new-content-guid"); - const [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/content`); - expect(init.method).toBe("POST"); - expect(JSON.parse(init.body)).toEqual({ name: "my-app" }); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe("/__api__/v1/content"); + expect(call.method).toBe("POST"); + expect(call.data).toEqual({ name: "my-app" }); }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("conflict", 409, "Conflict")); + mockRequest.mockResolvedValue( + textResponse("conflict", 409, "Conflict"), + ); const client = createClient(); await expect(client.createDeployment({ name: "dup" })).rejects.toThrow( @@ -333,8 +380,7 @@ describe("updateDeployment", () => { const contentId = "content-123" as ContentID; it("PATCHes the content and returns void", async () => { - const fetchSpy = vi.fn().mockResolvedValue(jsonResponse({}, 200)); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse({}, 200)); const client = createClient(); const result = await client.updateDeployment(contentId, { @@ -342,15 +388,15 @@ describe("updateDeployment", () => { }); expect(result).toBeUndefined(); - const [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}`); - expect(init.method).toBe("PATCH"); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe(`/__api__/v1/content/${contentId}`); + expect(call.method).toBe("PATCH"); }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("bad request", 400, "Bad Request")); + mockRequest.mockResolvedValue( + textResponse("bad request", 400, "Bad Request"), + ); const client = createClient(); await expect( @@ -368,7 +414,7 @@ describe("getEnvVars", () => { it("returns an array of environment variable names", async () => { const envNames = ["DATABASE_URL", "SECRET_KEY"]; - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(envNames)); + mockRequest.mockResolvedValue(jsonResponse(envNames)); const client = createClient(); const result = await client.getEnvVars(contentId); @@ -377,15 +423,14 @@ describe("getEnvVars", () => { }); it("calls the correct URL", async () => { - const fetchSpy = vi.fn().mockResolvedValue(jsonResponse([])); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse([])); const client = createClient(); await client.getEnvVars(contentId); - const [url] = fetchSpy.mock.calls[0]; - expect(url).toBe( - `${BASE_URL}/__api__/v1/content/${contentId}/environment`, + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe( + `/__api__/v1/content/${contentId}/environment`, ); }); }); @@ -398,29 +443,32 @@ describe("setEnvVars", () => { const contentId = "content-123" as ContentID; it("converts Record to [{name,value}] array in the body", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValue(new Response(null, { status: 204, statusText: "No Content" })); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue({ + status: 204, + statusText: "No Content", + data: null, + headers: {}, + config: {}, + }); const client = createClient(); await client.setEnvVars(contentId, { FOO: "bar", BAZ: "qux" }); - const [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe( - `${BASE_URL}/__api__/v1/content/${contentId}/environment`, + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe( + `/__api__/v1/content/${contentId}/environment`, ); - expect(init.method).toBe("PATCH"); - expect(JSON.parse(init.body)).toEqual([ + expect(call.method).toBe("PATCH"); + expect(call.data).toEqual([ { name: "FOO", value: "bar" }, { name: "BAZ", value: "qux" }, ]); }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("error", 500, "Internal Server Error")); + mockRequest.mockResolvedValue( + textResponse("error", 500, "Internal Server Error"), + ); const client = createClient(); await expect( @@ -437,26 +485,23 @@ describe("uploadBundle", () => { const contentId = "content-123" as ContentID; it("sends application/gzip with raw bytes and returns BundleID", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValue(jsonResponse({ id: "bundle-42" })); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse({ id: "bundle-42" })); const data = new Uint8Array([0x1f, 0x8b, 0x08]); const client = createClient(); const bundleId = await client.uploadBundle(contentId, data); expect(bundleId).toBe("bundle-42"); - const [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}/bundles`); - expect(init.method).toBe("POST"); - expect(init.headers["Content-Type"]).toBe("application/gzip"); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe(`/__api__/v1/content/${contentId}/bundles`); + expect(call.method).toBe("POST"); + expect(call.headers["Content-Type"]).toBe("application/gzip"); }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("too large", 413, "Payload Too Large")); + mockRequest.mockResolvedValue( + textResponse("too large", 413, "Payload Too Large"), + ); const client = createClient(); await expect( @@ -473,9 +518,9 @@ describe("latestBundleId", () => { const contentId = "content-123" as ContentID; it("returns bundle_id from content details", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(jsonResponse({ bundle_id: "latest-bundle" })); + mockRequest.mockResolvedValue( + jsonResponse({ bundle_id: "latest-bundle" }), + ); const client = createClient(); const bundleId = await client.latestBundleId(contentId); @@ -484,17 +529,14 @@ describe("latestBundleId", () => { }); it("calls GET on /__api__/v1/content/:id", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValue(jsonResponse({ bundle_id: "b1" })); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse({ bundle_id: "b1" })); const client = createClient(); await client.latestBundleId(contentId); - const [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}`); - expect(init.method).toBe("GET"); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe(`/__api__/v1/content/${contentId}`); + expect(call.method).toBe("GET"); }); }); @@ -508,7 +550,7 @@ describe("downloadBundle", () => { it("returns a Uint8Array of the bundle data", async () => { const data = new Uint8Array([1, 2, 3, 4, 5]); - globalThis.fetch = vi.fn().mockResolvedValue(binaryResponse(data)); + mockRequest.mockResolvedValue(binaryResponse(data)); const client = createClient(); const result = await client.downloadBundle(contentId, bundleId); @@ -517,24 +559,21 @@ describe("downloadBundle", () => { }); it("calls the correct download URL", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValue(binaryResponse(new Uint8Array())); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(binaryResponse(new Uint8Array())); const client = createClient(); await client.downloadBundle(contentId, bundleId); - const [url] = fetchSpy.mock.calls[0]; - expect(url).toBe( - `${BASE_URL}/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe( + `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, ); }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("not found", 404, "Not Found")); + mockRequest.mockResolvedValue( + textResponse("not found", 404, "Not Found"), + ); const client = createClient(); await expect( @@ -552,25 +591,22 @@ describe("deployBundle", () => { const bundleId = "bundle-42" as BundleID; it("POSTs {bundle_id} and returns task_id", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValue(jsonResponse({ task_id: "task-99" })); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse({ task_id: "task-99" })); const client = createClient(); const taskId = await client.deployBundle(contentId, bundleId); expect(taskId).toBe("task-99"); - const [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/content/${contentId}/deploy`); - expect(init.method).toBe("POST"); - expect(JSON.parse(init.body)).toEqual({ bundle_id: bundleId }); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe(`/__api__/v1/content/${contentId}/deploy`); + expect(call.method).toBe("POST"); + expect(call.data).toEqual({ bundle_id: bundleId }); }); it("throws ConnectRequestError on non-2xx", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("err", 500, "Internal Server Error")); + mockRequest.mockResolvedValue( + textResponse("err", 500, "Internal Server Error"), + ); const client = createClient(); await expect( @@ -587,7 +623,7 @@ describe("waitForTask", () => { const taskId = "task-99" as TaskID; it("returns {finished: true} when task completes without error", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( + mockRequest.mockResolvedValue( jsonResponse({ id: taskId, output: ["line 1"], @@ -606,7 +642,7 @@ describe("waitForTask", () => { }); it("throws TaskError when task finishes with an error", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( + mockRequest.mockResolvedValue( jsonResponse({ id: taskId, output: [], @@ -628,8 +664,7 @@ describe("waitForTask", () => { }); it("polls with first= query parameter and follows last cursor", async () => { - const fetchSpy = vi - .fn() + mockRequest .mockResolvedValueOnce( jsonResponse({ id: taskId, @@ -652,16 +687,15 @@ describe("waitForTask", () => { last: 5, }), ); - globalThis.fetch = fetchSpy; const client = createClient(); await client.waitForTask(taskId, 0); - expect(fetchSpy).toHaveBeenCalledTimes(2); - const [url1] = fetchSpy.mock.calls[0]; - const [url2] = fetchSpy.mock.calls[1]; - expect(url1).toBe(`${BASE_URL}/__api__/v1/tasks/${taskId}?first=0`); - expect(url2).toBe(`${BASE_URL}/__api__/v1/tasks/${taskId}?first=3`); + expect(mockRequest).toHaveBeenCalledTimes(2); + const url1 = mockRequest.mock.calls[0][0].url; + const url2 = mockRequest.mock.calls[1][0].url; + expect(url1).toBe(`/__api__/v1/tasks/${taskId}?first=0`); + expect(url2).toBe(`/__api__/v1/tasks/${taskId}?first=3`); }); }); @@ -673,7 +707,7 @@ describe("validateDeployment", () => { const contentId = "content-123" as ContentID; it("returns void on 200", async () => { - globalThis.fetch = vi.fn().mockResolvedValue(textResponse("OK", 200)); + mockRequest.mockResolvedValue(textResponse("OK", 200)); const client = createClient(); const result = await client.validateDeployment(contentId); @@ -682,9 +716,9 @@ describe("validateDeployment", () => { }); it("returns void on 404 (acceptable)", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("not found", 404, "Not Found")); + mockRequest.mockResolvedValue( + textResponse("not found", 404, "Not Found"), + ); const client = createClient(); const result = await client.validateDeployment(contentId); @@ -693,7 +727,7 @@ describe("validateDeployment", () => { }); it("throws DeploymentValidationError on 500", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( + mockRequest.mockResolvedValue( textResponse("server error", 500, "Internal Server Error"), ); @@ -704,9 +738,9 @@ describe("validateDeployment", () => { }); it("throws DeploymentValidationError on 502", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(textResponse("bad gateway", 502, "Bad Gateway")); + mockRequest.mockResolvedValue( + textResponse("bad gateway", 502, "Bad Gateway"), + ); const client = createClient(); await expect(client.validateDeployment(contentId)).rejects.toThrow( @@ -715,14 +749,13 @@ describe("validateDeployment", () => { }); it("calls GET /content/:id/", async () => { - const fetchSpy = vi.fn().mockResolvedValue(textResponse("OK", 200)); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(textResponse("OK", 200)); const client = createClient(); await client.validateDeployment(contentId); - const [url] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/content/${contentId}/`); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe(`/content/${contentId}/`); }); }); @@ -743,7 +776,7 @@ describe("getIntegrations", () => { created_time: "2024-01-01T00:00:00Z", }, ]; - globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(integrations)); + mockRequest.mockResolvedValue(jsonResponse(integrations)); const client = createClient(); const result = await client.getIntegrations(); @@ -752,14 +785,13 @@ describe("getIntegrations", () => { }); it("calls GET /__api__/v1/oauth/integrations", async () => { - const fetchSpy = vi.fn().mockResolvedValue(jsonResponse([])); - globalThis.fetch = fetchSpy; + mockRequest.mockResolvedValue(jsonResponse([])); const client = createClient(); await client.getIntegrations(); - const [url] = fetchSpy.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/__api__/v1/oauth/integrations`); + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe("/__api__/v1/oauth/integrations"); }); }); @@ -829,9 +861,8 @@ describe("getSettings", () => { installations: [{ version: "1.4.0", cluster_name: "", image_name: "" }], }; - it("makes 7 fetch calls to the correct URLs", async () => { - const fetchSpy = vi - .fn() + it("makes 7 request calls to the correct URLs", async () => { + mockRequest .mockResolvedValueOnce(jsonResponse(userDTO)) .mockResolvedValueOnce(jsonResponse(general)) .mockResolvedValueOnce(jsonResponse(application)) @@ -839,30 +870,28 @@ describe("getSettings", () => { .mockResolvedValueOnce(jsonResponse(python)) .mockResolvedValueOnce(jsonResponse(r)) .mockResolvedValueOnce(jsonResponse(quarto)); - globalThis.fetch = fetchSpy; const client = createClient(); await client.getSettings(); - expect(fetchSpy).toHaveBeenCalledTimes(7); + expect(mockRequest).toHaveBeenCalledTimes(7); - const urls = fetchSpy.mock.calls.map( - (call: unknown[]) => call[0] as string, + const urls = mockRequest.mock.calls.map( + (call: unknown[]) => (call[0] as { url: string }).url, ); expect(urls).toEqual([ - `${BASE_URL}/__api__/v1/user`, - `${BASE_URL}/__api__/server_settings`, - `${BASE_URL}/__api__/server_settings/applications`, - `${BASE_URL}/__api__/server_settings/scheduler`, - `${BASE_URL}/__api__/v1/server_settings/python`, - `${BASE_URL}/__api__/v1/server_settings/r`, - `${BASE_URL}/__api__/v1/server_settings/quarto`, + "/__api__/v1/user", + "/__api__/server_settings", + "/__api__/server_settings/applications", + "/__api__/server_settings/scheduler", + "/__api__/v1/server_settings/python", + "/__api__/v1/server_settings/r", + "/__api__/v1/server_settings/quarto", ]); }); it("returns an AllSettings composite object", async () => { - globalThis.fetch = vi - .fn() + mockRequest .mockResolvedValueOnce(jsonResponse(userDTO)) .mockResolvedValueOnce(jsonResponse(general)) .mockResolvedValueOnce(jsonResponse(application)) @@ -870,7 +899,6 @@ describe("getSettings", () => { .mockResolvedValueOnce(jsonResponse(python)) .mockResolvedValueOnce(jsonResponse(r)) .mockResolvedValueOnce(jsonResponse(quarto)); - globalThis.fetch = globalThis.fetch; const client = createClient(); const settings = await client.getSettings(); diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index 5d9184b8a..dd739ba80 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -1,5 +1,8 @@ // Copyright (C) 2026 by Posit Software, PBC. +import axios from "axios"; +import type { AxiosInstance, AxiosResponse } from "axios"; + import type { AllSettings, ApplicationSettings, @@ -31,16 +34,20 @@ import { /** * TypeScript client for the Posit Connect API. * - * Uses native fetch with zero runtime dependencies. + * Uses axios for HTTP requests. * Property names use snake_case to match the Connect API JSON wire format. */ export class ConnectAPI { - private readonly url: string; - private readonly apiKey: string; + private readonly client: AxiosInstance; constructor(options: ConnectAPIOptions) { - this.url = options.url; - this.apiKey = options.apiKey; + this.client = axios.create({ + baseURL: options.url, + headers: { + Authorization: `Key ${options.apiKey}`, + }, + validateStatus: () => true, + }); } // --------------------------------------------------------------------------- @@ -54,24 +61,28 @@ export class ConnectAPI { body?: unknown; contentType?: string; rawBody?: Uint8Array; + responseType?: "arraybuffer"; }, - ): Promise { - const url = `${this.url}${path}`; - const headers: Record = { - Authorization: `Key ${this.apiKey}`, - }; + ): Promise { + const headers: Record = {}; - let body: BodyInit | undefined; + let data: unknown; if (options?.rawBody) { headers["Content-Type"] = options.contentType ?? "application/octet-stream"; - body = Buffer.from(options.rawBody); + data = options.rawBody; } else if (options?.body !== undefined) { headers["Content-Type"] = options.contentType ?? "application/json"; - body = JSON.stringify(options.body); + data = options.body; } - return fetch(url, { method, headers, body }); + return this.client.request({ + method, + url: path, + headers, + data, + responseType: options?.responseType, + }); } private async requestJson( @@ -80,11 +91,14 @@ export class ConnectAPI { options?: { body?: unknown }, ): Promise { const resp = await this.request(method, path, options); - if (!resp.ok) { - const body = await resp.text(); + if (resp.status < 200 || resp.status >= 300) { + const body = + typeof resp.data === "string" + ? resp.data + : JSON.stringify(resp.data ?? ""); throw new ConnectRequestError(resp.status, resp.statusText, body); } - return resp.json() as Promise; + return resp.data as T; } // --------------------------------------------------------------------------- @@ -101,16 +115,13 @@ export class ConnectAPI { }> { const resp = await this.request("GET", "/__api__/v1/user"); - if (!resp.ok) { - const errorBody = (await resp.json().catch(() => ({}))) as Record< - string, - unknown - >; + if (resp.status < 200 || resp.status >= 300) { + const errorBody = (resp.data ?? {}) as Record; const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`; throw new AuthenticationError(msg); } - const dto = (await resp.json()) as UserDTO; + const dto = resp.data as UserDTO; if (dto.locked) { const msg = `user account ${dto.username} is locked`; @@ -181,8 +192,11 @@ export class ConnectAPI { body, }, ); - if (!resp.ok) { - const respBody = await resp.text(); + if (resp.status < 200 || resp.status >= 300) { + const respBody = + typeof resp.data === "string" + ? resp.data + : JSON.stringify(resp.data ?? ""); throw new ConnectRequestError(resp.status, resp.statusText, respBody); } } @@ -206,8 +220,11 @@ export class ConnectAPI { `/__api__/v1/content/${contentId}/environment`, { body }, ); - if (!resp.ok) { - const respBody = await resp.text(); + if (resp.status < 200 || resp.status >= 300) { + const respBody = + typeof resp.data === "string" + ? resp.data + : JSON.stringify(resp.data ?? ""); throw new ConnectRequestError(resp.status, resp.statusText, respBody); } } @@ -222,11 +239,14 @@ export class ConnectAPI { `/__api__/v1/content/${contentId}/bundles`, { rawBody: data, contentType: "application/gzip" }, ); - if (!resp.ok) { - const body = await resp.text(); + if (resp.status < 200 || resp.status >= 300) { + const body = + typeof resp.data === "string" + ? resp.data + : JSON.stringify(resp.data ?? ""); throw new ConnectRequestError(resp.status, resp.statusText, body); } - const bundle = (await resp.json()) as { id: string }; + const bundle = resp.data as { id: string }; return bundle.id as BundleID; } @@ -247,13 +267,16 @@ export class ConnectAPI { const resp = await this.request( "GET", `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + { responseType: "arraybuffer" }, ); - if (!resp.ok) { - const body = await resp.text(); + if (resp.status < 200 || resp.status >= 300) { + const body = + typeof resp.data === "string" + ? resp.data + : JSON.stringify(resp.data ?? ""); throw new ConnectRequestError(resp.status, resp.statusText, body); } - const buffer = await resp.arrayBuffer(); - return new Uint8Array(buffer); + return new Uint8Array(resp.data as ArrayBuffer); } /** Initiates deployment of a specific bundle and returns the task ID. */ @@ -306,7 +329,6 @@ export class ConnectAPI { */ async validateDeployment(contentId: ContentID): Promise { const resp = await this.request("GET", `/content/${contentId}/`); - await resp.text(); // consume body if (resp.status >= 500) { throw new DeploymentValidationError(contentId, resp.status); } From eed513d79f7b45b2b4e9795b3205147a2bf6ba6b Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:49:18 -0400 Subject: [PATCH 08/13] refactor: return raw API responses from ConnectAPI methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop constructing new objects in client methods — return the exact response data from the Connect API instead. Removes the User type (a client-side invention) and updates the contract test adapter to extract fields from full DTOs. Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/src/client.test.ts | 90 ++++++++----------- packages/connect-api/src/client.ts | 61 ++++--------- packages/connect-api/src/index.ts | 1 - packages/connect-api/src/types.ts | 11 +-- .../src/clients/ts-direct-client.ts | 16 ++-- 5 files changed, 66 insertions(+), 113 deletions(-) diff --git a/packages/connect-api/src/client.test.ts b/packages/connect-api/src/client.test.ts index 4f4379af9..85a0de92e 100644 --- a/packages/connect-api/src/client.test.ts +++ b/packages/connect-api/src/client.test.ts @@ -147,21 +147,14 @@ describe("Authorization header", () => { // --------------------------------------------------------------------------- describe("testAuthentication", () => { - it("returns user with guid mapped to id on success", async () => { + it("returns the full UserDTO on success", async () => { const dto = validUserDTO(); mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); const result = await client.testAuthentication(); - expect(result.error).toBeNull(); - expect(result.user).toEqual({ - id: dto.guid, - username: dto.username, - first_name: dto.first_name, - last_name: dto.last_name, - email: dto.email, - }); + expect(result).toEqual(dto); }); it("throws AuthenticationError on 401", async () => { @@ -211,8 +204,7 @@ describe("testAuthentication", () => { const client = createClient(); const result = await client.testAuthentication(); - expect(result.user).not.toBeNull(); - expect(result.error).toBeNull(); + expect(result).toEqual(dto); }); it("throws AuthenticationError with generic message on non-JSON error body", async () => { @@ -230,20 +222,14 @@ describe("testAuthentication", () => { // --------------------------------------------------------------------------- describe("getCurrentUser", () => { - it("maps guid to id and returns User", async () => { + it("returns the full UserDTO", async () => { const dto = validUserDTO(); mockRequest.mockResolvedValue(jsonResponse(dto)); const client = createClient(); const user = await client.getCurrentUser(); - expect(user).toEqual({ - id: dto.guid, - username: dto.username, - first_name: dto.first_name, - last_name: dto.last_name, - email: dto.email, - }); + expect(user).toEqual(dto); }); it("throws ConnectRequestError on non-2xx", async () => { @@ -345,15 +331,14 @@ describe("contentDetails", () => { // --------------------------------------------------------------------------- describe("createDeployment", () => { - it("POSTs the body and returns the guid as ContentID", async () => { - mockRequest.mockResolvedValue( - jsonResponse({ guid: "new-content-guid" }), - ); + it("POSTs the body and returns the full content details", async () => { + const responseBody = { guid: "new-content-guid", name: "my-app", title: null }; + mockRequest.mockResolvedValue(jsonResponse(responseBody)); const client = createClient(); - const id = await client.createDeployment({ name: "my-app" }); + const result = await client.createDeployment({ name: "my-app" }); - expect(id).toBe("new-content-guid"); + expect(result).toEqual(responseBody); const call = mockRequest.mock.calls[0][0]; expect(call.url).toBe("/__api__/v1/content"); expect(call.method).toBe("POST"); @@ -484,14 +469,15 @@ describe("setEnvVars", () => { describe("uploadBundle", () => { const contentId = "content-123" as ContentID; - it("sends application/gzip with raw bytes and returns BundleID", async () => { - mockRequest.mockResolvedValue(jsonResponse({ id: "bundle-42" })); + it("sends application/gzip with raw bytes and returns full BundleDTO", async () => { + const bundleResponse = { id: "bundle-42", content_guid: contentId, active: true, size: 1024 }; + mockRequest.mockResolvedValue(jsonResponse(bundleResponse)); const data = new Uint8Array([0x1f, 0x8b, 0x08]); const client = createClient(); - const bundleId = await client.uploadBundle(contentId, data); + const result = await client.uploadBundle(contentId, data); - expect(bundleId).toBe("bundle-42"); + expect(result).toEqual(bundleResponse); const call = mockRequest.mock.calls[0][0]; expect(call.url).toBe(`/__api__/v1/content/${contentId}/bundles`); expect(call.method).toBe("POST"); @@ -517,15 +503,15 @@ describe("uploadBundle", () => { describe("latestBundleId", () => { const contentId = "content-123" as ContentID; - it("returns bundle_id from content details", async () => { - mockRequest.mockResolvedValue( - jsonResponse({ bundle_id: "latest-bundle" }), - ); + it("returns the full content details including bundle_id", async () => { + const responseBody = { guid: contentId, bundle_id: "latest-bundle", name: "my-app" }; + mockRequest.mockResolvedValue(jsonResponse(responseBody)); const client = createClient(); - const bundleId = await client.latestBundleId(contentId); + const result = await client.latestBundleId(contentId); - expect(bundleId).toBe("latest-bundle"); + expect(result).toEqual(responseBody); + expect(result.bundle_id).toBe("latest-bundle"); }); it("calls GET on /__api__/v1/content/:id", async () => { @@ -590,13 +576,14 @@ describe("deployBundle", () => { const contentId = "content-123" as ContentID; const bundleId = "bundle-42" as BundleID; - it("POSTs {bundle_id} and returns task_id", async () => { - mockRequest.mockResolvedValue(jsonResponse({ task_id: "task-99" })); + it("POSTs {bundle_id} and returns the full deploy output", async () => { + const deployResponse = { task_id: "task-99" }; + mockRequest.mockResolvedValue(jsonResponse(deployResponse)); const client = createClient(); - const taskId = await client.deployBundle(contentId, bundleId); + const result = await client.deployBundle(contentId, bundleId); - expect(taskId).toBe("task-99"); + expect(result).toEqual(deployResponse); const call = mockRequest.mock.calls[0][0]; expect(call.url).toBe(`/__api__/v1/content/${contentId}/deploy`); expect(call.method).toBe("POST"); @@ -622,23 +609,22 @@ describe("deployBundle", () => { describe("waitForTask", () => { const taskId = "task-99" as TaskID; - it("returns {finished: true} when task completes without error", async () => { - mockRequest.mockResolvedValue( - jsonResponse({ - id: taskId, - output: ["line 1"], - result: null, - finished: true, - code: 0, - error: "", - last: 1, - }), - ); + it("returns the full TaskDTO when task completes without error", async () => { + const taskResponse = { + id: taskId, + output: ["line 1"], + result: null, + finished: true, + code: 0, + error: "", + last: 1, + }; + mockRequest.mockResolvedValue(jsonResponse(taskResponse)); const client = createClient(); const result = await client.waitForTask(taskId, 0); - expect(result).toEqual({ finished: true }); + expect(result).toEqual(taskResponse); }); it("throws TaskError when task finishes with an error", async () => { diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index dd739ba80..d7d5a3376 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -6,6 +6,7 @@ import type { AxiosInstance, AxiosResponse } from "axios"; import type { AllSettings, ApplicationSettings, + BundleDTO, BundleID, ConnectAPIOptions, ConnectContent, @@ -20,7 +21,6 @@ import type { ServerSettings, TaskDTO, TaskID, - User, UserDTO, } from "./types.js"; @@ -107,12 +107,9 @@ export class ConnectAPI { /** * Validates credentials and checks user state (locked, confirmed, role). - * Returns `{ user, error: null }` on success; throws AuthenticationError otherwise. + * Returns the full UserDTO on success; throws AuthenticationError otherwise. */ - async testAuthentication(): Promise<{ - user: User | null; - error: { msg: string } | null; - }> { + async testAuthentication(): Promise { const resp = await this.request("GET", "/__api__/v1/user"); if (resp.status < 200 || resp.status >= 300) { @@ -138,28 +135,12 @@ export class ConnectAPI { throw new AuthenticationError(msg); } - return { - user: { - id: dto.guid, - username: dto.username, - first_name: dto.first_name, - last_name: dto.last_name, - email: dto.email, - }, - error: null, - }; + return dto; } /** Retrieves the current authenticated user without validation checks. */ - async getCurrentUser(): Promise { - const dto = await this.requestJson("GET", "/__api__/v1/user"); - return { - id: dto.guid, - username: dto.username, - first_name: dto.first_name, - last_name: dto.last_name, - email: dto.email, - }; + async getCurrentUser(): Promise { + return this.requestJson("GET", "/__api__/v1/user"); } /** Fetches details for a content item by ID. */ @@ -170,14 +151,13 @@ export class ConnectAPI { ); } - /** Creates a new content item and returns its GUID. */ - async createDeployment(body: ConnectContent): Promise { - const content = await this.requestJson<{ guid: string }>( + /** Creates a new content item and returns the full content details. */ + async createDeployment(body: ConnectContent): Promise { + return this.requestJson( "POST", "/__api__/v1/content", { body }, ); - return content.guid as ContentID; } /** Updates an existing content item. */ @@ -233,7 +213,7 @@ export class ConnectAPI { async uploadBundle( contentId: ContentID, data: Uint8Array, - ): Promise { + ): Promise { const resp = await this.request( "POST", `/__api__/v1/content/${contentId}/bundles`, @@ -246,17 +226,15 @@ export class ConnectAPI { : JSON.stringify(resp.data ?? ""); throw new ConnectRequestError(resp.status, resp.statusText, body); } - const bundle = resp.data as { id: string }; - return bundle.id as BundleID; + return resp.data as BundleDTO; } - /** Retrieves the latest bundle ID from a content item's details. */ - async latestBundleId(contentId: ContentID): Promise { - const content = await this.requestJson<{ bundle_id: string }>( + /** Retrieves content details (including bundle_id) for a content item. */ + async latestBundleId(contentId: ContentID): Promise { + return this.requestJson( "GET", `/__api__/v1/content/${contentId}`, ); - return content.bundle_id as BundleID; } /** Downloads a bundle archive as raw bytes. */ @@ -279,17 +257,16 @@ export class ConnectAPI { return new Uint8Array(resp.data as ArrayBuffer); } - /** Initiates deployment of a specific bundle and returns the task ID. */ + /** Initiates deployment of a specific bundle. */ async deployBundle( contentId: ContentID, bundleId: BundleID, - ): Promise { - const result = await this.requestJson( + ): Promise { + return this.requestJson( "POST", `/__api__/v1/content/${contentId}/deploy`, { body: { bundle_id: bundleId } }, ); - return result.task_id as TaskID; } /** @@ -299,7 +276,7 @@ export class ConnectAPI { async waitForTask( taskId: TaskID, pollIntervalMs = 500, - ): Promise<{ finished: true }> { + ): Promise { let firstLine = 0; for (;;) { @@ -312,7 +289,7 @@ export class ConnectAPI { if (task.error) { throw new TaskError(taskId, task.error, task.code); } - return { finished: true }; + return task; } firstLine = task.last; diff --git a/packages/connect-api/src/index.ts b/packages/connect-api/src/index.ts index e7b84cd5f..025967f47 100644 --- a/packages/connect-api/src/index.ts +++ b/packages/connect-api/src/index.ts @@ -26,7 +26,6 @@ export type { ServerSettings, TaskDTO, TaskID, - User, UserDTO, UserID, } from "./types.js"; diff --git a/packages/connect-api/src/types.ts b/packages/connect-api/src/types.ts index 69022f3c4..69b5e7dcc 100644 --- a/packages/connect-api/src/types.ts +++ b/packages/connect-api/src/types.ts @@ -23,16 +23,7 @@ export interface ConnectAPIOptions { // User types // --------------------------------------------------------------------------- -/** Mapped user returned by the client (guid → id). */ -export interface User { - id: string; - username: string; - first_name: string; - last_name: string; - email: string; -} - -/** Raw user DTO from the Connect API. */ +/** User DTO from the Connect API. */ export interface UserDTO { guid: string; username: string; diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts index 4510f6870..67c0d3ab0 100644 --- a/test/connect-api-contracts/src/clients/ts-direct-client.ts +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -80,10 +80,10 @@ export class TypeScriptDirectClient implements ConnectContractClient { return c.contentDetails(params.contentId as ContentID); case Method.CreateDeployment: { - const contentId = await c.createDeployment( + const content = await c.createDeployment( (params.body as Record) ?? {}, ); - return { contentId }; + return { contentId: content.guid }; } case Method.UpdateDeployment: @@ -104,16 +104,16 @@ export class TypeScriptDirectClient implements ConnectContractClient { return undefined; case Method.UploadBundle: { - const bundleId = await c.uploadBundle( + const bundle = await c.uploadBundle( params.contentId as ContentID, params.bundleData as Uint8Array, ); - return { bundleId }; + return { bundleId: bundle.id }; } case Method.LatestBundleID: { - const bundleId = await c.latestBundleId(params.contentId as ContentID); - return { bundleId }; + const content = await c.latestBundleId(params.contentId as ContentID); + return { bundleId: content.bundle_id }; } case Method.DownloadBundle: @@ -123,11 +123,11 @@ export class TypeScriptDirectClient implements ConnectContractClient { ); case Method.DeployBundle: { - const taskId = await c.deployBundle( + const deploy = await c.deployBundle( params.contentId as ContentID, params.bundleId as BundleID, ); - return { taskId }; + return { taskId: deploy.task_id }; } case Method.WaitForTask: From ed28b63d54ffe6c5cf5bf737237f68b8e68a01c7 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:23:58 -0400 Subject: [PATCH 09/13] refactor: remove custom error classes, rely on default axios throws Let axios throw AxiosError on non-2xx responses instead of manually checking status codes and throwing custom error types. Two methods use per-request validateStatus overrides: testAuthentication inspects error response bodies, and validateDeployment accepts non-5xx codes. Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/src/client.test.ts | 160 +++++++++++++----------- packages/connect-api/src/client.ts | 87 +++---------- packages/connect-api/src/errors.test.ts | 83 ------------ packages/connect-api/src/errors.ts | 52 -------- packages/connect-api/src/index.ts | 8 -- 5 files changed, 110 insertions(+), 280 deletions(-) delete mode 100644 packages/connect-api/src/errors.test.ts delete mode 100644 packages/connect-api/src/errors.ts diff --git a/packages/connect-api/src/client.test.ts b/packages/connect-api/src/client.test.ts index 85a0de92e..7473927e1 100644 --- a/packages/connect-api/src/client.test.ts +++ b/packages/connect-api/src/client.test.ts @@ -1,14 +1,7 @@ // Copyright (C) 2026 by Posit Software, PBC. import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Mock } from "vitest"; import { ConnectAPI } from "./client.js"; -import { - AuthenticationError, - ConnectRequestError, - DeploymentValidationError, - TaskError, -} from "./errors.js"; import type { BundleID, ContentID, @@ -18,20 +11,32 @@ import type { } from "./types.js"; // --------------------------------------------------------------------------- -// Mock axios +// Mock axios — simulates real axios throw behavior on non-2xx // --------------------------------------------------------------------------- const mockRequest = vi.fn(); -vi.mock("axios", () => { - return { - default: { - create: vi.fn(() => ({ - request: mockRequest, - })), - }, - }; -}); +vi.mock("axios", () => ({ + default: { + create: vi.fn(() => ({ + request: async (config: Record) => { + const resp = await mockRequest(config); + const validate = + (config.validateStatus as + | ((s: number) => boolean) + | undefined) ?? + ((s: number) => s >= 200 && s < 300); + if (!validate(resp.status as number)) { + throw Object.assign( + new Error(`Request failed with status code ${resp.status}`), + { isAxiosError: true, response: resp }, + ); + } + return resp; + }, + })), + }, +})); // Re-import axios so we can inspect axios.create calls import axios from "axios"; @@ -51,7 +56,13 @@ function jsonResponse( body: unknown, status = 200, statusText = "OK", -): { status: number; statusText: string; data: unknown; headers: Record; config: object } { +): { + status: number; + statusText: string; + data: unknown; + headers: Record; + config: object; +} { return { status, statusText, @@ -65,7 +76,13 @@ function textResponse( body: string, status = 200, statusText = "OK", -): { status: number; statusText: string; data: string; headers: Record; config: object } { +): { + status: number; + statusText: string; + data: string; + headers: Record; + config: object; +} { return { status, statusText, @@ -78,8 +95,13 @@ function textResponse( function binaryResponse( data: Uint8Array, status = 200, -): { status: number; statusText: string; data: ArrayBuffer; headers: Record; config: object } { - // Convert Uint8Array to ArrayBuffer (axios returns ArrayBuffer with responseType: "arraybuffer") +): { + status: number; + statusText: string; + data: ArrayBuffer; + headers: Record; + config: object; +} { const buffer = data.buffer.slice( data.byteOffset, data.byteOffset + data.byteLength, @@ -157,18 +179,16 @@ describe("testAuthentication", () => { expect(result).toEqual(dto); }); - it("throws AuthenticationError on 401", async () => { + it("throws on 401", async () => { mockRequest.mockResolvedValue( jsonResponse({ error: "Unauthorized" }, 401, "Unauthorized"), ); const client = createClient(); - await expect(client.testAuthentication()).rejects.toThrow( - AuthenticationError, - ); + await expect(client.testAuthentication()).rejects.toThrow("Unauthorized"); }); - it("throws AuthenticationError when user is locked", async () => { + it("throws when user is locked", async () => { const dto = validUserDTO({ locked: true }); mockRequest.mockResolvedValue(jsonResponse(dto)); @@ -178,7 +198,7 @@ describe("testAuthentication", () => { ); }); - it("throws AuthenticationError when user is not confirmed", async () => { + it("throws when user is not confirmed", async () => { const dto = validUserDTO({ confirmed: false }); mockRequest.mockResolvedValue(jsonResponse(dto)); @@ -188,7 +208,7 @@ describe("testAuthentication", () => { ); }); - it("throws AuthenticationError when user is a viewer", async () => { + it("throws when user is a viewer", async () => { const dto = validUserDTO({ user_role: "viewer" }); mockRequest.mockResolvedValue(jsonResponse(dto)); @@ -207,13 +227,11 @@ describe("testAuthentication", () => { expect(result).toEqual(dto); }); - it("throws AuthenticationError with generic message on non-JSON error body", async () => { + it("throws with generic message on non-JSON error body", async () => { mockRequest.mockResolvedValue(textResponse("not json", 403, "Forbidden")); const client = createClient(); - await expect(client.testAuthentication()).rejects.toThrow( - AuthenticationError, - ); + await expect(client.testAuthentication()).rejects.toThrow("HTTP 403"); }); }); @@ -232,13 +250,13 @@ describe("getCurrentUser", () => { expect(user).toEqual(dto); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("err", 500, "Internal Server Error"), ); const client = createClient(); - await expect(client.getCurrentUser()).rejects.toThrow(ConnectRequestError); + await expect(client.getCurrentUser()).rejects.toThrow(); }); it("calls GET /__api__/v1/user", async () => { @@ -314,15 +332,13 @@ describe("contentDetails", () => { expect(call.url).toBe(`/__api__/v1/content/${contentId}`); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("not found", 404, "Not Found"), ); const client = createClient(); - await expect(client.contentDetails(contentId)).rejects.toThrow( - ConnectRequestError, - ); + await expect(client.contentDetails(contentId)).rejects.toThrow(); }); }); @@ -332,7 +348,11 @@ describe("contentDetails", () => { describe("createDeployment", () => { it("POSTs the body and returns the full content details", async () => { - const responseBody = { guid: "new-content-guid", name: "my-app", title: null }; + const responseBody = { + guid: "new-content-guid", + name: "my-app", + title: null, + }; mockRequest.mockResolvedValue(jsonResponse(responseBody)); const client = createClient(); @@ -345,15 +365,13 @@ describe("createDeployment", () => { expect(call.data).toEqual({ name: "my-app" }); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("conflict", 409, "Conflict"), ); const client = createClient(); - await expect(client.createDeployment({ name: "dup" })).rejects.toThrow( - ConnectRequestError, - ); + await expect(client.createDeployment({ name: "dup" })).rejects.toThrow(); }); }); @@ -378,7 +396,7 @@ describe("updateDeployment", () => { expect(call.method).toBe("PATCH"); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("bad request", 400, "Bad Request"), ); @@ -386,7 +404,7 @@ describe("updateDeployment", () => { const client = createClient(); await expect( client.updateDeployment(contentId, { title: "x" }), - ).rejects.toThrow(ConnectRequestError); + ).rejects.toThrow(); }); }); @@ -450,7 +468,7 @@ describe("setEnvVars", () => { ]); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("error", 500, "Internal Server Error"), ); @@ -458,7 +476,7 @@ describe("setEnvVars", () => { const client = createClient(); await expect( client.setEnvVars(contentId, { KEY: "val" }), - ).rejects.toThrow(ConnectRequestError); + ).rejects.toThrow(); }); }); @@ -470,7 +488,12 @@ describe("uploadBundle", () => { const contentId = "content-123" as ContentID; it("sends application/gzip with raw bytes and returns full BundleDTO", async () => { - const bundleResponse = { id: "bundle-42", content_guid: contentId, active: true, size: 1024 }; + const bundleResponse = { + id: "bundle-42", + content_guid: contentId, + active: true, + size: 1024, + }; mockRequest.mockResolvedValue(jsonResponse(bundleResponse)); const data = new Uint8Array([0x1f, 0x8b, 0x08]); @@ -484,7 +507,7 @@ describe("uploadBundle", () => { expect(call.headers["Content-Type"]).toBe("application/gzip"); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("too large", 413, "Payload Too Large"), ); @@ -492,7 +515,7 @@ describe("uploadBundle", () => { const client = createClient(); await expect( client.uploadBundle(contentId, new Uint8Array([1])), - ).rejects.toThrow(ConnectRequestError); + ).rejects.toThrow(); }); }); @@ -504,7 +527,11 @@ describe("latestBundleId", () => { const contentId = "content-123" as ContentID; it("returns the full content details including bundle_id", async () => { - const responseBody = { guid: contentId, bundle_id: "latest-bundle", name: "my-app" }; + const responseBody = { + guid: contentId, + bundle_id: "latest-bundle", + name: "my-app", + }; mockRequest.mockResolvedValue(jsonResponse(responseBody)); const client = createClient(); @@ -556,7 +583,7 @@ describe("downloadBundle", () => { ); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("not found", 404, "Not Found"), ); @@ -564,7 +591,7 @@ describe("downloadBundle", () => { const client = createClient(); await expect( client.downloadBundle(contentId, bundleId), - ).rejects.toThrow(ConnectRequestError); + ).rejects.toThrow(); }); }); @@ -590,7 +617,7 @@ describe("deployBundle", () => { expect(call.data).toEqual({ bundle_id: bundleId }); }); - it("throws ConnectRequestError on non-2xx", async () => { + it("throws on non-2xx", async () => { mockRequest.mockResolvedValue( textResponse("err", 500, "Internal Server Error"), ); @@ -598,7 +625,7 @@ describe("deployBundle", () => { const client = createClient(); await expect( client.deployBundle(contentId, bundleId), - ).rejects.toThrow(ConnectRequestError); + ).rejects.toThrow(); }); }); @@ -627,7 +654,7 @@ describe("waitForTask", () => { expect(result).toEqual(taskResponse); }); - it("throws TaskError when task finishes with an error", async () => { + it("throws when task finishes with an error", async () => { mockRequest.mockResolvedValue( jsonResponse({ id: taskId, @@ -641,12 +668,9 @@ describe("waitForTask", () => { ); const client = createClient(); - const err = await client - .waitForTask(taskId, 0) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(TaskError); - expect((err as TaskError).message).toMatch(/deployment failed/); - expect((err as TaskError).code).toBe(1); + await expect(client.waitForTask(taskId, 0)).rejects.toThrow( + "deployment failed", + ); }); it("polls with first= query parameter and follows last cursor", async () => { @@ -712,26 +736,22 @@ describe("validateDeployment", () => { expect(result).toBeUndefined(); }); - it("throws DeploymentValidationError on 500", async () => { + it("throws on 500", async () => { mockRequest.mockResolvedValue( textResponse("server error", 500, "Internal Server Error"), ); const client = createClient(); - await expect(client.validateDeployment(contentId)).rejects.toThrow( - DeploymentValidationError, - ); + await expect(client.validateDeployment(contentId)).rejects.toThrow(); }); - it("throws DeploymentValidationError on 502", async () => { + it("throws on 502", async () => { mockRequest.mockResolvedValue( textResponse("bad gateway", 502, "Bad Gateway"), ); const client = createClient(); - await expect(client.validateDeployment(contentId)).rejects.toThrow( - DeploymentValidationError, - ); + await expect(client.validateDeployment(contentId)).rejects.toThrow(); }); it("calls GET /content/:id/", async () => { diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index d7d5a3376..c6c47780a 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -24,17 +24,10 @@ import type { UserDTO, } from "./types.js"; -import { - AuthenticationError, - ConnectRequestError, - DeploymentValidationError, - TaskError, -} from "./errors.js"; - /** * TypeScript client for the Posit Connect API. * - * Uses axios for HTTP requests. + * Uses axios for HTTP requests. Non-2xx responses throw AxiosError by default. * Property names use snake_case to match the Connect API JSON wire format. */ export class ConnectAPI { @@ -46,7 +39,6 @@ export class ConnectAPI { headers: { Authorization: `Key ${options.apiKey}`, }, - validateStatus: () => true, }); } @@ -62,6 +54,7 @@ export class ConnectAPI { contentType?: string; rawBody?: Uint8Array; responseType?: "arraybuffer"; + validateStatus?: (status: number) => boolean; }, ): Promise { const headers: Record = {}; @@ -82,6 +75,7 @@ export class ConnectAPI { headers, data, responseType: options?.responseType, + validateStatus: options?.validateStatus, }); } @@ -91,13 +85,6 @@ export class ConnectAPI { options?: { body?: unknown }, ): Promise { const resp = await this.request(method, path, options); - if (resp.status < 200 || resp.status >= 300) { - const body = - typeof resp.data === "string" - ? resp.data - : JSON.stringify(resp.data ?? ""); - throw new ConnectRequestError(resp.status, resp.statusText, body); - } return resp.data as T; } @@ -107,32 +94,33 @@ export class ConnectAPI { /** * Validates credentials and checks user state (locked, confirmed, role). - * Returns the full UserDTO on success; throws AuthenticationError otherwise. + * Returns the full UserDTO on success; throws on HTTP errors or invalid state. */ async testAuthentication(): Promise { - const resp = await this.request("GET", "/__api__/v1/user"); + const resp = await this.request("GET", "/__api__/v1/user", { + validateStatus: () => true, + }); if (resp.status < 200 || resp.status >= 300) { const errorBody = (resp.data ?? {}) as Record; const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`; - throw new AuthenticationError(msg); + throw new Error(msg); } const dto = resp.data as UserDTO; if (dto.locked) { - const msg = `user account ${dto.username} is locked`; - throw new AuthenticationError(msg); + throw new Error(`user account ${dto.username} is locked`); } if (!dto.confirmed) { - const msg = `user account ${dto.username} is not confirmed`; - throw new AuthenticationError(msg); + throw new Error(`user account ${dto.username} is not confirmed`); } if (dto.user_role !== "publisher" && dto.user_role !== "administrator") { - const msg = `user account ${dto.username} with role '${dto.user_role}' does not have permission to publish content`; - throw new AuthenticationError(msg); + throw new Error( + `user account ${dto.username} with role '${dto.user_role}' does not have permission to publish content`, + ); } return dto; @@ -165,20 +153,7 @@ export class ConnectAPI { contentId: ContentID, body: ConnectContent, ): Promise { - const resp = await this.request( - "PATCH", - `/__api__/v1/content/${contentId}`, - { - body, - }, - ); - if (resp.status < 200 || resp.status >= 300) { - const respBody = - typeof resp.data === "string" - ? resp.data - : JSON.stringify(resp.data ?? ""); - throw new ConnectRequestError(resp.status, resp.statusText, respBody); - } + await this.request("PATCH", `/__api__/v1/content/${contentId}`, { body }); } /** Retrieves environment variable names for a content item. */ @@ -195,18 +170,11 @@ export class ConnectAPI { env: Record, ): Promise { const body = Object.entries(env).map(([name, value]) => ({ name, value })); - const resp = await this.request( + await this.request( "PATCH", `/__api__/v1/content/${contentId}/environment`, { body }, ); - if (resp.status < 200 || resp.status >= 300) { - const respBody = - typeof resp.data === "string" - ? resp.data - : JSON.stringify(resp.data ?? ""); - throw new ConnectRequestError(resp.status, resp.statusText, respBody); - } } /** Uploads a bundle archive (gzip) for a content item. */ @@ -219,13 +187,6 @@ export class ConnectAPI { `/__api__/v1/content/${contentId}/bundles`, { rawBody: data, contentType: "application/gzip" }, ); - if (resp.status < 200 || resp.status >= 300) { - const body = - typeof resp.data === "string" - ? resp.data - : JSON.stringify(resp.data ?? ""); - throw new ConnectRequestError(resp.status, resp.statusText, body); - } return resp.data as BundleDTO; } @@ -247,13 +208,6 @@ export class ConnectAPI { `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, { responseType: "arraybuffer" }, ); - if (resp.status < 200 || resp.status >= 300) { - const body = - typeof resp.data === "string" - ? resp.data - : JSON.stringify(resp.data ?? ""); - throw new ConnectRequestError(resp.status, resp.statusText, body); - } return new Uint8Array(resp.data as ArrayBuffer); } @@ -287,7 +241,7 @@ export class ConnectAPI { if (task.finished) { if (task.error) { - throw new TaskError(taskId, task.error, task.code); + throw new Error(task.error); } return task; } @@ -302,13 +256,12 @@ export class ConnectAPI { /** * Validates that deployed content is reachable by hitting its content URL. - * Status >= 500 is an error; 404 and other codes are acceptable. + * Status >= 500 throws; 404 and other codes are acceptable. */ async validateDeployment(contentId: ContentID): Promise { - const resp = await this.request("GET", `/content/${contentId}/`); - if (resp.status >= 500) { - throw new DeploymentValidationError(contentId, resp.status); - } + await this.request("GET", `/content/${contentId}/`, { + validateStatus: (status) => status < 500, + }); } /** Retrieves OAuth integrations from the server. */ diff --git a/packages/connect-api/src/errors.test.ts b/packages/connect-api/src/errors.test.ts deleted file mode 100644 index 9dbce5fdc..000000000 --- a/packages/connect-api/src/errors.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (C) 2026 by Posit Software, PBC. - -import { describe, expect, it } from "vitest"; -import { - AuthenticationError, - ConnectAPIError, - ConnectRequestError, - DeploymentValidationError, - TaskError, -} from "./errors.js"; - -describe("ConnectAPIError", () => { - it("is an instance of Error", () => { - const err = new ConnectAPIError("base error"); - expect(err).toBeInstanceOf(Error); - expect(err.name).toBe("ConnectAPIError"); - expect(err.message).toBe("base error"); - }); -}); - -describe("ConnectRequestError", () => { - it("extends ConnectAPIError", () => { - const err = new ConnectRequestError(404, "Not Found", "page missing"); - expect(err).toBeInstanceOf(ConnectAPIError); - expect(err).toBeInstanceOf(Error); - }); - - it("carries status, statusText, and body", () => { - const err = new ConnectRequestError(500, "Internal Server Error", "oops"); - expect(err.status).toBe(500); - expect(err.statusText).toBe("Internal Server Error"); - expect(err.body).toBe("oops"); - expect(err.name).toBe("ConnectRequestError"); - expect(err.message).toBe("HTTP 500: Internal Server Error"); - }); -}); - -describe("AuthenticationError", () => { - it("extends ConnectAPIError", () => { - const err = new AuthenticationError("locked"); - expect(err).toBeInstanceOf(ConnectAPIError); - expect(err).toBeInstanceOf(Error); - }); - - it("has the correct name and message", () => { - const err = new AuthenticationError("account locked"); - expect(err.name).toBe("AuthenticationError"); - expect(err.message).toBe("account locked"); - }); -}); - -describe("TaskError", () => { - it("extends ConnectAPIError", () => { - const err = new TaskError("task-1", "failed to build", 1); - expect(err).toBeInstanceOf(ConnectAPIError); - expect(err).toBeInstanceOf(Error); - }); - - it("carries taskId, errorMessage, and code", () => { - const err = new TaskError("task-abc", "timeout", 137); - expect(err.taskId).toBe("task-abc"); - expect(err.errorMessage).toBe("timeout"); - expect(err.code).toBe(137); - expect(err.name).toBe("TaskError"); - expect(err.message).toBe("timeout"); - }); -}); - -describe("DeploymentValidationError", () => { - it("extends ConnectAPIError", () => { - const err = new DeploymentValidationError("content-1", 502); - expect(err).toBeInstanceOf(ConnectAPIError); - expect(err).toBeInstanceOf(Error); - }); - - it("carries contentId and httpStatus", () => { - const err = new DeploymentValidationError("content-xyz", 500); - expect(err.contentId).toBe("content-xyz"); - expect(err.httpStatus).toBe(500); - expect(err.name).toBe("DeploymentValidationError"); - expect(err.message).toBe("deployed content does not seem to be running"); - }); -}); diff --git a/packages/connect-api/src/errors.ts b/packages/connect-api/src/errors.ts deleted file mode 100644 index b83d2d134..000000000 --- a/packages/connect-api/src/errors.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (C) 2026 by Posit Software, PBC. - -/** Base error class for all Connect client errors. */ -export class ConnectAPIError extends Error { - constructor(message: string) { - super(message); - this.name = "ConnectAPIError"; - } -} - -/** HTTP request returned a non-2xx status. */ -export class ConnectRequestError extends ConnectAPIError { - constructor( - public readonly status: number, - public readonly statusText: string, - public readonly body: string, - ) { - super(`HTTP ${status}: ${statusText}`); - this.name = "ConnectRequestError"; - } -} - -/** User account is locked, unconfirmed, or has insufficient role. */ -export class AuthenticationError extends ConnectAPIError { - constructor(message: string) { - super(message); - this.name = "AuthenticationError"; - } -} - -/** Task finished with a non-zero exit code or error message. */ -export class TaskError extends ConnectAPIError { - constructor( - public readonly taskId: string, - public readonly errorMessage: string, - public readonly code: number, - ) { - super(errorMessage); - this.name = "TaskError"; - } -} - -/** Deployed content URL returned a 5xx status. */ -export class DeploymentValidationError extends ConnectAPIError { - constructor( - public readonly contentId: string, - public readonly httpStatus: number, - ) { - super("deployed content does not seem to be running"); - this.name = "DeploymentValidationError"; - } -} diff --git a/packages/connect-api/src/index.ts b/packages/connect-api/src/index.ts index 025967f47..b7e90f3f3 100644 --- a/packages/connect-api/src/index.ts +++ b/packages/connect-api/src/index.ts @@ -29,11 +29,3 @@ export type { UserDTO, UserID, } from "./types.js"; - -export { - AuthenticationError, - ConnectAPIError, - ConnectRequestError, - DeploymentValidationError, - TaskError, -} from "./errors.js"; From f3273d46be0ef78d80976fc8fbbfa5bfaa531428 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:26:53 -0400 Subject: [PATCH 10/13] refactor: remove private request helpers, use axios client directly Each method now calls this.client.request() directly with its own config, eliminating the request() and requestJson() indirection. Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/src/client.ts | 247 +++++++++++++---------------- 1 file changed, 110 insertions(+), 137 deletions(-) diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index c6c47780a..071bc1149 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -1,7 +1,7 @@ // Copyright (C) 2026 by Posit Software, PBC. import axios from "axios"; -import type { AxiosInstance, AxiosResponse } from "axios"; +import type { AxiosInstance } from "axios"; import type { AllSettings, @@ -42,62 +42,14 @@ export class ConnectAPI { }); } - // --------------------------------------------------------------------------- - // HTTP helpers - // --------------------------------------------------------------------------- - - private async request( - method: string, - path: string, - options?: { - body?: unknown; - contentType?: string; - rawBody?: Uint8Array; - responseType?: "arraybuffer"; - validateStatus?: (status: number) => boolean; - }, - ): Promise { - const headers: Record = {}; - - let data: unknown; - if (options?.rawBody) { - headers["Content-Type"] = - options.contentType ?? "application/octet-stream"; - data = options.rawBody; - } else if (options?.body !== undefined) { - headers["Content-Type"] = options.contentType ?? "application/json"; - data = options.body; - } - - return this.client.request({ - method, - url: path, - headers, - data, - responseType: options?.responseType, - validateStatus: options?.validateStatus, - }); - } - - private async requestJson( - method: string, - path: string, - options?: { body?: unknown }, - ): Promise { - const resp = await this.request(method, path, options); - return resp.data as T; - } - - // --------------------------------------------------------------------------- - // API methods - // --------------------------------------------------------------------------- - /** * Validates credentials and checks user state (locked, confirmed, role). * Returns the full UserDTO on success; throws on HTTP errors or invalid state. */ async testAuthentication(): Promise { - const resp = await this.request("GET", "/__api__/v1/user", { + const resp = await this.client.request({ + method: "GET", + url: "/__api__/v1/user", validateStatus: () => true, }); @@ -107,45 +59,51 @@ export class ConnectAPI { throw new Error(msg); } - const dto = resp.data as UserDTO; + const data = resp.data as UserDTO; - if (dto.locked) { - throw new Error(`user account ${dto.username} is locked`); + if (data.locked) { + throw new Error(`user account ${data.username} is locked`); } - if (!dto.confirmed) { - throw new Error(`user account ${dto.username} is not confirmed`); + if (!data.confirmed) { + throw new Error(`user account ${data.username} is not confirmed`); } - if (dto.user_role !== "publisher" && dto.user_role !== "administrator") { + if (data.user_role !== "publisher" && data.user_role !== "administrator") { throw new Error( - `user account ${dto.username} with role '${dto.user_role}' does not have permission to publish content`, + `user account ${data.username} with role '${data.user_role}' does not have permission to publish content`, ); } - return dto; + return data; } /** Retrieves the current authenticated user without validation checks. */ async getCurrentUser(): Promise { - return this.requestJson("GET", "/__api__/v1/user"); + const { data } = await this.client.request({ + method: "GET", + url: "/__api__/v1/user", + }); + return data; } /** Fetches details for a content item by ID. */ async contentDetails(contentId: ContentID): Promise { - return this.requestJson( - "GET", - `/__api__/v1/content/${contentId}`, - ); + const { data } = await this.client.request({ + method: "GET", + url: `/__api__/v1/content/${contentId}`, + }); + return data; } /** Creates a new content item and returns the full content details. */ async createDeployment(body: ConnectContent): Promise { - return this.requestJson( - "POST", - "/__api__/v1/content", - { body }, - ); + const { data } = await this.client.request({ + method: "POST", + url: "/__api__/v1/content", + data: body, + }); + return data; } /** Updates an existing content item. */ @@ -153,15 +111,20 @@ export class ConnectAPI { contentId: ContentID, body: ConnectContent, ): Promise { - await this.request("PATCH", `/__api__/v1/content/${contentId}`, { body }); + await this.client.request({ + method: "PATCH", + url: `/__api__/v1/content/${contentId}`, + data: body, + }); } /** Retrieves environment variable names for a content item. */ async getEnvVars(contentId: ContentID): Promise { - return this.requestJson( - "GET", - `/__api__/v1/content/${contentId}/environment`, - ); + const { data } = await this.client.request({ + method: "GET", + url: `/__api__/v1/content/${contentId}/environment`, + }); + return data; } /** Sets environment variables for a content item. */ @@ -169,33 +132,34 @@ export class ConnectAPI { contentId: ContentID, env: Record, ): Promise { - const body = Object.entries(env).map(([name, value]) => ({ name, value })); - await this.request( - "PATCH", - `/__api__/v1/content/${contentId}/environment`, - { body }, - ); + await this.client.request({ + method: "PATCH", + url: `/__api__/v1/content/${contentId}/environment`, + data: Object.entries(env).map(([name, value]) => ({ name, value })), + }); } /** Uploads a bundle archive (gzip) for a content item. */ async uploadBundle( contentId: ContentID, - data: Uint8Array, + bundle: Uint8Array, ): Promise { - const resp = await this.request( - "POST", - `/__api__/v1/content/${contentId}/bundles`, - { rawBody: data, contentType: "application/gzip" }, - ); - return resp.data as BundleDTO; + const { data } = await this.client.request({ + method: "POST", + url: `/__api__/v1/content/${contentId}/bundles`, + data: bundle, + headers: { "Content-Type": "application/gzip" }, + }); + return data; } /** Retrieves content details (including bundle_id) for a content item. */ async latestBundleId(contentId: ContentID): Promise { - return this.requestJson( - "GET", - `/__api__/v1/content/${contentId}`, - ); + const { data } = await this.client.request({ + method: "GET", + url: `/__api__/v1/content/${contentId}`, + }); + return data; } /** Downloads a bundle archive as raw bytes. */ @@ -203,12 +167,12 @@ export class ConnectAPI { contentId: ContentID, bundleId: BundleID, ): Promise { - const resp = await this.request( - "GET", - `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, - { responseType: "arraybuffer" }, - ); - return new Uint8Array(resp.data as ArrayBuffer); + const { data } = await this.client.request({ + method: "GET", + url: `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + responseType: "arraybuffer", + }); + return new Uint8Array(data); } /** Initiates deployment of a specific bundle. */ @@ -216,11 +180,12 @@ export class ConnectAPI { contentId: ContentID, bundleId: BundleID, ): Promise { - return this.requestJson( - "POST", - `/__api__/v1/content/${contentId}/deploy`, - { body: { bundle_id: bundleId } }, - ); + const { data } = await this.client.request({ + method: "POST", + url: `/__api__/v1/content/${contentId}/deploy`, + data: { bundle_id: bundleId }, + }); + return data; } /** @@ -234,10 +199,10 @@ export class ConnectAPI { let firstLine = 0; for (;;) { - const task = await this.requestJson( - "GET", - `/__api__/v1/tasks/${taskId}?first=${firstLine}`, - ); + const { data: task } = await this.client.request({ + method: "GET", + url: `/__api__/v1/tasks/${taskId}?first=${firstLine}`, + }); if (task.finished) { if (task.error) { @@ -259,17 +224,20 @@ export class ConnectAPI { * Status >= 500 throws; 404 and other codes are acceptable. */ async validateDeployment(contentId: ContentID): Promise { - await this.request("GET", `/content/${contentId}/`, { + await this.client.request({ + method: "GET", + url: `/content/${contentId}/`, validateStatus: (status) => status < 500, }); } /** Retrieves OAuth integrations from the server. */ async getIntegrations(): Promise { - return this.requestJson( - "GET", - "/__api__/v1/oauth/integrations", - ); + const { data } = await this.client.request({ + method: "GET", + url: "/__api__/v1/oauth/integrations", + }); + return data; } /** @@ -277,31 +245,36 @@ export class ConnectAPI { * mirroring the Go client's GetSettings behavior. */ async getSettings(): Promise { - const user = await this.requestJson("GET", "/__api__/v1/user"); - const General = await this.requestJson( - "GET", - "/__api__/server_settings", - ); - const application = await this.requestJson( - "GET", - "/__api__/server_settings/applications", - ); - const scheduler = await this.requestJson( - "GET", - "/__api__/server_settings/scheduler", - ); - const python = await this.requestJson( - "GET", - "/__api__/v1/server_settings/python", - ); - const r = await this.requestJson( - "GET", - "/__api__/v1/server_settings/r", - ); - const quarto = await this.requestJson( - "GET", - "/__api__/v1/server_settings/quarto", - ); + const { data: user } = await this.client.request({ + method: "GET", + url: "/__api__/v1/user", + }); + const { data: General } = await this.client.request({ + method: "GET", + url: "/__api__/server_settings", + }); + const { data: application } = + await this.client.request({ + method: "GET", + url: "/__api__/server_settings/applications", + }); + const { data: scheduler } = + await this.client.request({ + method: "GET", + url: "/__api__/server_settings/scheduler", + }); + const { data: python } = await this.client.request({ + method: "GET", + url: "/__api__/v1/server_settings/python", + }); + const { data: r } = await this.client.request({ + method: "GET", + url: "/__api__/v1/server_settings/r", + }); + const { data: quarto } = await this.client.request({ + method: "GET", + url: "/__api__/v1/server_settings/quarto", + }); return { General, user, application, scheduler, python, r, quarto }; } From bbf41e4df43474443fc387a784a47747b1bc5e45 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:36:52 -0400 Subject: [PATCH 11/13] refactor: add response generic to testAuthentication request Cast through unknown on the error path so the request can use generic, giving strong typing on the success path. Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/src/client.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index 071bc1149..f4f8b2b49 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -47,20 +47,18 @@ export class ConnectAPI { * Returns the full UserDTO on success; throws on HTTP errors or invalid state. */ async testAuthentication(): Promise { - const resp = await this.client.request({ + const { data, status } = await this.client.request({ method: "GET", url: "/__api__/v1/user", validateStatus: () => true, }); - if (resp.status < 200 || resp.status >= 300) { - const errorBody = (resp.data ?? {}) as Record; - const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`; + if (status < 200 || status >= 300) { + const errorBody = ((data as unknown) ?? {}) as Record; + const msg = (errorBody.error as string) ?? `HTTP ${status}`; throw new Error(msg); } - const data = resp.data as UserDTO; - if (data.locked) { throw new Error(`user account ${data.username} is locked`); } From e8aa9dc6952af7adf4ce1937f1dd788bd36e61da Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:47:51 -0400 Subject: [PATCH 12/13] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/src/client.test.ts | 36 +++++++------------------ packages/connect-api/src/client.ts | 14 ++++------ packages/connect-api/src/index.ts | 2 +- 3 files changed, 15 insertions(+), 37 deletions(-) diff --git a/packages/connect-api/src/client.test.ts b/packages/connect-api/src/client.test.ts index 7473927e1..ae5cd034e 100644 --- a/packages/connect-api/src/client.test.ts +++ b/packages/connect-api/src/client.test.ts @@ -22,9 +22,7 @@ vi.mock("axios", () => ({ request: async (config: Record) => { const resp = await mockRequest(config); const validate = - (config.validateStatus as - | ((s: number) => boolean) - | undefined) ?? + (config.validateStatus as ((s: number) => boolean) | undefined) ?? ((s: number) => s >= 200 && s < 300); if (!validate(resp.status as number)) { throw Object.assign( @@ -333,9 +331,7 @@ describe("contentDetails", () => { }); it("throws on non-2xx", async () => { - mockRequest.mockResolvedValue( - textResponse("not found", 404, "Not Found"), - ); + mockRequest.mockResolvedValue(textResponse("not found", 404, "Not Found")); const client = createClient(); await expect(client.contentDetails(contentId)).rejects.toThrow(); @@ -366,9 +362,7 @@ describe("createDeployment", () => { }); it("throws on non-2xx", async () => { - mockRequest.mockResolvedValue( - textResponse("conflict", 409, "Conflict"), - ); + mockRequest.mockResolvedValue(textResponse("conflict", 409, "Conflict")); const client = createClient(); await expect(client.createDeployment({ name: "dup" })).rejects.toThrow(); @@ -432,9 +426,7 @@ describe("getEnvVars", () => { await client.getEnvVars(contentId); const call = mockRequest.mock.calls[0][0]; - expect(call.url).toBe( - `/__api__/v1/content/${contentId}/environment`, - ); + expect(call.url).toBe(`/__api__/v1/content/${contentId}/environment`); }); }); @@ -458,9 +450,7 @@ describe("setEnvVars", () => { await client.setEnvVars(contentId, { FOO: "bar", BAZ: "qux" }); const call = mockRequest.mock.calls[0][0]; - expect(call.url).toBe( - `/__api__/v1/content/${contentId}/environment`, - ); + expect(call.url).toBe(`/__api__/v1/content/${contentId}/environment`); expect(call.method).toBe("PATCH"); expect(call.data).toEqual([ { name: "FOO", value: "bar" }, @@ -584,14 +574,10 @@ describe("downloadBundle", () => { }); it("throws on non-2xx", async () => { - mockRequest.mockResolvedValue( - textResponse("not found", 404, "Not Found"), - ); + mockRequest.mockResolvedValue(textResponse("not found", 404, "Not Found")); const client = createClient(); - await expect( - client.downloadBundle(contentId, bundleId), - ).rejects.toThrow(); + await expect(client.downloadBundle(contentId, bundleId)).rejects.toThrow(); }); }); @@ -623,9 +609,7 @@ describe("deployBundle", () => { ); const client = createClient(); - await expect( - client.deployBundle(contentId, bundleId), - ).rejects.toThrow(); + await expect(client.deployBundle(contentId, bundleId)).rejects.toThrow(); }); }); @@ -726,9 +710,7 @@ describe("validateDeployment", () => { }); it("returns void on 404 (acceptable)", async () => { - mockRequest.mockResolvedValue( - textResponse("not found", 404, "Not Found"), - ); + mockRequest.mockResolvedValue(textResponse("not found", 404, "Not Found")); const client = createClient(); const result = await client.validateDeployment(contentId); diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index f4f8b2b49..a7fd50d9f 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -190,10 +190,7 @@ export class ConnectAPI { * Polls for task completion. * @param pollIntervalMs - milliseconds between polls (default 500, pass 0 for tests) */ - async waitForTask( - taskId: TaskID, - pollIntervalMs = 500, - ): Promise { + async waitForTask(taskId: TaskID, pollIntervalMs = 500): Promise { let firstLine = 0; for (;;) { @@ -256,11 +253,10 @@ export class ConnectAPI { method: "GET", url: "/__api__/server_settings/applications", }); - const { data: scheduler } = - await this.client.request({ - method: "GET", - url: "/__api__/server_settings/scheduler", - }); + const { data: scheduler } = await this.client.request({ + method: "GET", + url: "/__api__/server_settings/scheduler", + }); const { data: python } = await this.client.request({ method: "GET", url: "/__api__/v1/server_settings/python", diff --git a/packages/connect-api/src/index.ts b/packages/connect-api/src/index.ts index b7e90f3f3..b9ffa0c9c 100644 --- a/packages/connect-api/src/index.ts +++ b/packages/connect-api/src/index.ts @@ -1,6 +1,6 @@ // Copyright (C) 2026 by Posit Software, PBC. -export { ConnectAPI} from "./client.js"; +export { ConnectAPI } from "./client.js"; export type { AllSettings, From 6f510ac185a6a805948be3b1d493a23466f95d94 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:50:11 -0400 Subject: [PATCH 13/13] style: replace for(;;) with while(true) in waitForTask Co-Authored-By: Claude Opus 4.6 --- packages/connect-api/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index a7fd50d9f..b19c9dd96 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -193,7 +193,7 @@ export class ConnectAPI { async waitForTask(taskId: TaskID, pollIntervalMs = 500): Promise { let firstLine = 0; - for (;;) { + while (true) { const { data: task } = await this.client.request({ method: "GET", url: `/__api__/v1/tasks/${taskId}?first=${firstLine}`,