diff --git a/sdks/sandbox/javascript/package.json b/sdks/sandbox/javascript/package.json index cf5b7263..f1fb98bc 100644 --- a/sdks/sandbox/javascript/package.json +++ b/sdks/sandbox/javascript/package.json @@ -42,7 +42,10 @@ "gen:api": "node ./scripts/generate-api.mjs", "build": "pnpm run gen:api && tsup", "lint": "eslint src scripts --max-warnings 0", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "openapi-fetch": "^0.14.1", @@ -50,11 +53,13 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.2", "globals": "^17.0.0", "openapi-typescript": "^7.9.1", "tsup": "^8.5.0", "typescript": "^5.7.2", - "typescript-eslint": "^8.52.0" + "typescript-eslint": "^8.52.0", + "vitest": "^4.0.18" } } \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/adapters/poolsAdapter.ts b/sdks/sandbox/javascript/src/adapters/poolsAdapter.ts new file mode 100644 index 00000000..7c3c38d9 --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/poolsAdapter.ts @@ -0,0 +1,161 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import createClient from "openapi-fetch"; +import type { Client } from "openapi-fetch"; + +import { throwOnOpenApiFetchError } from "./openapiError.js"; +import type { poolPaths, PoolComponents } from "../api/lifecycle.js"; +import type { Pools } from "../services/pools.js"; +import type { + CreatePoolRequest, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + PoolStatus, + UpdatePoolRequest, +} from "../models/pools.js"; + +type PoolClient = Client; + +type ApiPoolResponse = PoolComponents["schemas"]["PoolResponse"]; +type ApiPoolCapacitySpec = PoolComponents["schemas"]["PoolCapacitySpec"]; +type ApiPoolStatus = PoolComponents["schemas"]["PoolStatus"]; + +// ---- helpers --------------------------------------------------------------- + +function parseOptionalDate(field: string, v: unknown): Date | undefined { + if (v === undefined || v === null) return undefined; + if (typeof v !== "string" || !v) { + throw new Error(`Invalid ${field}: expected ISO string, got ${typeof v}`); + } + const d = new Date(v); + if (Number.isNaN(d.getTime())) throw new Error(`Invalid ${field}: ${v}`); + return d; +} + +function mapCapacitySpec(raw: ApiPoolCapacitySpec): PoolCapacitySpec { + return { + bufferMax: raw.bufferMax, + bufferMin: raw.bufferMin, + poolMax: raw.poolMax, + poolMin: raw.poolMin, + }; +} + +function mapPoolStatus(raw: ApiPoolStatus): PoolStatus { + return { + total: raw.total, + allocated: raw.allocated, + available: raw.available, + revision: raw.revision, + }; +} + +function mapPoolInfo(raw: ApiPoolResponse): PoolInfo { + return { + name: raw.name, + capacitySpec: mapCapacitySpec(raw.capacitySpec), + status: raw.status ? mapPoolStatus(raw.status) : undefined, + createdAt: parseOptionalDate("createdAt", raw.createdAt), + }; +} + +// ---- adapter --------------------------------------------------------------- + +/** + * HTTP adapter implementing the {@link Pools} service interface. + * + * Uses an `openapi-fetch` client typed against the pool path definitions in + * `api/lifecycle.ts` to ensure the request/response shapes stay in sync. + */ +export class PoolsAdapter implements Pools { + private readonly client: PoolClient; + + constructor(opts: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + fetch?: typeof fetch; + }) { + const headers: Record = { ...(opts.headers ?? {}) }; + if (opts.apiKey && !headers["OPEN-SANDBOX-API-KEY"]) { + headers["OPEN-SANDBOX-API-KEY"] = opts.apiKey; + } + + const createClientFn = + (createClient as unknown as { default?: typeof createClient }).default ?? + createClient; + this.client = createClientFn({ + baseUrl: opts.baseUrl ?? "http://localhost:8080/v1", + headers, + fetch: opts.fetch, + }); + } + + async createPool(req: CreatePoolRequest): Promise { + const { data, error, response } = await this.client.POST("/pools", { + body: req as PoolComponents["schemas"]["CreatePoolRequest"], + }); + throwOnOpenApiFetchError({ error, response }, "Create pool failed"); + const raw = data as ApiPoolResponse | undefined; + if (!raw || typeof raw !== "object") { + throw new Error("Create pool failed: unexpected response shape"); + } + return mapPoolInfo(raw); + } + + async getPool(poolName: string): Promise { + const { data, error, response } = await this.client.GET("/pools/{poolName}", { + params: { path: { poolName } }, + }); + throwOnOpenApiFetchError({ error, response }, `Get pool '${poolName}' failed`); + const raw = data as ApiPoolResponse | undefined; + if (!raw || typeof raw !== "object") { + throw new Error(`Get pool '${poolName}' failed: unexpected response shape`); + } + return mapPoolInfo(raw); + } + + async listPools(): Promise { + const { data, error, response } = await this.client.GET("/pools", {}); + throwOnOpenApiFetchError({ error, response }, "List pools failed"); + const raw = data as PoolComponents["schemas"]["ListPoolsResponse"] | undefined; + if (!raw || typeof raw !== "object") { + throw new Error("List pools failed: unexpected response shape"); + } + const items = Array.isArray(raw.items) ? raw.items.map(mapPoolInfo) : []; + return { items }; + } + + async updatePool(poolName: string, req: UpdatePoolRequest): Promise { + const { data, error, response } = await this.client.PUT("/pools/{poolName}", { + params: { path: { poolName } }, + body: req as PoolComponents["schemas"]["UpdatePoolRequest"], + }); + throwOnOpenApiFetchError({ error, response }, `Update pool '${poolName}' failed`); + const raw = data as ApiPoolResponse | undefined; + if (!raw || typeof raw !== "object") { + throw new Error(`Update pool '${poolName}' failed: unexpected response shape`); + } + return mapPoolInfo(raw); + } + + async deletePool(poolName: string): Promise { + const { error, response } = await this.client.DELETE("/pools/{poolName}", { + params: { path: { poolName } }, + }); + throwOnOpenApiFetchError({ error, response }, `Delete pool '${poolName}' failed`); + } +} diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index 571eac18..1ed8ca91 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -901,3 +901,128 @@ export interface components { } export type $defs = Record; export type operations = Record; + +// ============================================================================ +// Pool paths and components (manually added – not auto-generated) +// ============================================================================ + +export interface PoolComponents { + schemas: { + PoolCapacitySpec: { + bufferMax: number; + bufferMin: number; + poolMax: number; + poolMin: number; + }; + PoolStatus: { + total: number; + allocated: number; + available: number; + revision: string; + }; + PoolResponse: { + name: string; + capacitySpec: PoolComponents["schemas"]["PoolCapacitySpec"]; + status?: PoolComponents["schemas"]["PoolStatus"]; + /** @description ISO 8601 timestamp */ + createdAt?: string; + }; + ListPoolsResponse: { + items: PoolComponents["schemas"]["PoolResponse"][]; + }; + CreatePoolRequest: { + name: string; + template: Record; + capacitySpec: PoolComponents["schemas"]["PoolCapacitySpec"]; + }; + UpdatePoolRequest: { + capacitySpec: PoolComponents["schemas"]["PoolCapacitySpec"]; + }; + }; +} + +export interface poolPaths { + "/pools": { + parameters: { query?: never; header?: never; path?: never; cookie?: never }; + /** List all pre-warmed resource pools */ + get: { + parameters: { query?: never; header?: never; path?: never; cookie?: never }; + requestBody?: never; + responses: { + 200: { content: { "application/json": PoolComponents["schemas"]["ListPoolsResponse"] } }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + 501: { content: { "application/json": components["schemas"]["ErrorResponse"] } }; + }; + }; + put?: never; + /** Create a pre-warmed resource pool */ + post: { + parameters: { query?: never; header?: never; path?: never; cookie?: never }; + requestBody: { content: { "application/json": PoolComponents["schemas"]["CreatePoolRequest"] } }; + responses: { + 201: { content: { "application/json": PoolComponents["schemas"]["PoolResponse"] } }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + 501: { content: { "application/json": components["schemas"]["ErrorResponse"] } }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pools/{poolName}": { + parameters: { + query?: never; + header?: never; + path: { poolName: string }; + cookie?: never; + }; + /** Retrieve a pool by name */ + get: { + parameters: { query?: never; header?: never; path: { poolName: string }; cookie?: never }; + requestBody?: never; + responses: { + 200: { content: { "application/json": PoolComponents["schemas"]["PoolResponse"] } }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 501: { content: { "application/json": components["schemas"]["ErrorResponse"] } }; + }; + }; + /** Update pool capacity configuration */ + put: { + parameters: { query?: never; header?: never; path: { poolName: string }; cookie?: never }; + requestBody: { content: { "application/json": PoolComponents["schemas"]["UpdatePoolRequest"] } }; + responses: { + 200: { content: { "application/json": PoolComponents["schemas"]["PoolResponse"] } }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 501: { content: { "application/json": components["schemas"]["ErrorResponse"] } }; + }; + }; + post?: never; + /** Delete a pool */ + delete: { + parameters: { query?: never; header?: never; path: { poolName: string }; cookie?: never }; + requestBody?: never; + responses: { + 204: { content?: never }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 501: { content: { "application/json": components["schemas"]["ErrorResponse"] } }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} diff --git a/sdks/sandbox/javascript/src/factory/adapterFactory.ts b/sdks/sandbox/javascript/src/factory/adapterFactory.ts index e007bbe3..5b8b98bc 100644 --- a/sdks/sandbox/javascript/src/factory/adapterFactory.ts +++ b/sdks/sandbox/javascript/src/factory/adapterFactory.ts @@ -18,6 +18,7 @@ import type { ExecdCommands } from "../services/execdCommands.js"; import type { ExecdHealth } from "../services/execdHealth.js"; import type { ExecdMetrics } from "../services/execdMetrics.js"; import type { Sandboxes } from "../services/sandboxes.js"; +import type { Pools } from "../services/pools.js"; export interface CreateLifecycleStackOptions { connectionConfig: ConnectionConfig; @@ -26,6 +27,7 @@ export interface CreateLifecycleStackOptions { export interface LifecycleStack { sandboxes: Sandboxes; + pools: Pools; } export interface CreateExecdStackOptions { diff --git a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts index f912fb19..1bb34170 100644 --- a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts +++ b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts @@ -19,6 +19,7 @@ import { CommandsAdapter } from "../adapters/commandsAdapter.js"; import { FilesystemAdapter } from "../adapters/filesystemAdapter.js"; import { HealthAdapter } from "../adapters/healthAdapter.js"; import { MetricsAdapter } from "../adapters/metricsAdapter.js"; +import { PoolsAdapter } from "../adapters/poolsAdapter.js"; import { SandboxesAdapter } from "../adapters/sandboxesAdapter.js"; import type { AdapterFactory, CreateExecdStackOptions, CreateLifecycleStackOptions, ExecdStack, LifecycleStack } from "./adapterFactory.js"; @@ -32,7 +33,13 @@ export class DefaultAdapterFactory implements AdapterFactory { fetch: opts.connectionConfig.fetch, }); const sandboxes = new SandboxesAdapter(lifecycleClient); - return { sandboxes }; + const pools = new PoolsAdapter({ + baseUrl: opts.lifecycleBaseUrl, + apiKey: opts.connectionConfig.apiKey, + headers: opts.connectionConfig.headers, + fetch: opts.connectionConfig.fetch, + }); + return { sandboxes, pools }; } createExecdStack(opts: CreateExecdStackOptions): ExecdStack { diff --git a/sdks/sandbox/javascript/src/index.ts b/sdks/sandbox/javascript/src/index.ts index 00f1e051..03f1ce15 100644 --- a/sdks/sandbox/javascript/src/index.ts +++ b/sdks/sandbox/javascript/src/index.ts @@ -112,4 +112,20 @@ export type { SetPermissionEntry, WriteEntry, } from "./models/filesystem.js"; -export type { SandboxFiles } from "./services/filesystem.js"; \ No newline at end of file +export type { SandboxFiles } from "./services/filesystem.js"; + +// Pool management +export type { + CreatePoolRequest, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + PoolStatus, + UpdatePoolRequest, +} from "./models/pools.js"; +export type { Pools } from "./services/pools.js"; +export { PoolManager } from "./poolManager.js"; +export type { PoolManagerOptions } from "./poolManager.js"; + +// Pool management – synchronous (Node.js only) +export { PoolManagerSync } from "./poolManagerSync.js"; \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/models/pools.ts b/sdks/sandbox/javascript/src/models/pools.ts new file mode 100644 index 00000000..eb64f6e3 --- /dev/null +++ b/sdks/sandbox/javascript/src/models/pools.ts @@ -0,0 +1,110 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Domain models for pre-warmed sandbox resource pools. + * + * Pools are Kubernetes CRD resources that keep a set of pods pre-warmed, + * reducing sandbox cold-start latency. + * + * IMPORTANT: + * - These are NOT OpenAPI-generated types. + * - They are intentionally stable and JS-friendly. + */ + +/** + * Capacity configuration that controls how many pods are kept warm + * and the overall pool size limits. + */ +export interface PoolCapacitySpec { + /** Maximum number of pods kept in the warm buffer. */ + bufferMax: number; + /** Minimum number of pods that must remain in the warm buffer. */ + bufferMin: number; + /** Maximum total number of pods allowed in the entire pool. */ + poolMax: number; + /** Minimum total size of the pool. */ + poolMin: number; +} + +/** + * Observed runtime state of a pool, reported by the Kubernetes controller. + */ +export interface PoolStatus { + /** Total number of pods in the pool (warm + allocated). */ + total: number; + /** Number of pods currently allocated to running sandboxes. */ + allocated: number; + /** Number of pods currently available in the warm buffer. */ + available: number; + /** Latest revision identifier of the pool spec. */ + revision: string; +} + +/** + * Full representation of a Pool resource. + * + * Returned by create / get / update operations and as items in list responses. + */ +export interface PoolInfo { + /** Unique pool name (Kubernetes resource name). */ + name: string; + /** Capacity configuration of the pool. */ + capacitySpec: PoolCapacitySpec; + /** + * Observed runtime state of the pool. + * May be undefined if the controller has not yet reconciled the pool. + */ + status?: PoolStatus; + /** Pool creation timestamp. */ + createdAt?: Date; +} + +/** + * Response returned by the list pools endpoint. + */ +export interface PoolListResponse { + /** All pools in the namespace. */ + items: PoolInfo[]; +} + +/** + * Request body for creating a new Pool. + */ +export interface CreatePoolRequest { + /** + * Unique name for the pool. + * Must be a valid Kubernetes resource name: lowercase alphanumeric and hyphens, + * starting and ending with alphanumeric characters. + */ + name: string; + /** + * Kubernetes PodTemplateSpec defining the pod configuration for pre-warmed pods. + * Follows the same schema as `spec.template` in a Kubernetes Deployment. + */ + template: Record; + /** Initial capacity configuration. */ + capacitySpec: PoolCapacitySpec; +} + +/** + * Request body for updating an existing Pool's capacity. + * + * Only `capacitySpec` can be changed after pool creation. + * To change the pod template, delete and recreate the pool. + */ +export interface UpdatePoolRequest { + /** New capacity configuration for the pool. */ + capacitySpec: PoolCapacitySpec; +} diff --git a/sdks/sandbox/javascript/src/poolManager.ts b/sdks/sandbox/javascript/src/poolManager.ts new file mode 100644 index 00000000..836d9bc0 --- /dev/null +++ b/sdks/sandbox/javascript/src/poolManager.ts @@ -0,0 +1,160 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ConnectionConfig, type ConnectionConfigOptions } from "./config/connection.js"; +import { PoolsAdapter } from "./adapters/poolsAdapter.js"; +import type { Pools } from "./services/pools.js"; +import type { + CreatePoolRequest, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + UpdatePoolRequest, +} from "./models/pools.js"; + +export interface PoolManagerOptions { + /** + * Connection configuration for calling the OpenSandbox Lifecycle API. + */ + connectionConfig?: ConnectionConfig | ConnectionConfigOptions; +} + +/** + * High-level interface for managing pre-warmed sandbox resource pools. + * + * Pools are Kubernetes CRD resources that keep a set of pods pre-warmed, + * reducing sandbox cold-start latency. + * + * @example + * ```typescript + * const manager = PoolManager.create(); + * + * // Create a pool + * const pool = await manager.createPool({ + * name: "my-pool", + * template: { spec: { containers: [{ name: "sandbox", image: "python:3.11" }] } }, + * capacitySpec: { bufferMax: 3, bufferMin: 1, poolMax: 10, poolMin: 0 }, + * }); + * + * // List / get / update / delete + * const pools = await manager.listPools(); + * const info = await manager.getPool("my-pool"); + * const updated = await manager.updatePool("my-pool", { + * capacitySpec: { bufferMax: 5, bufferMin: 2, poolMax: 20, poolMin: 0 }, + * }); + * await manager.deletePool("my-pool"); + * + * await manager.close(); + * ``` + * + * **Note**: Pool management requires the server to be configured with + * `runtime.type = 'kubernetes'`. Non-Kubernetes deployments return + * `SandboxApiException` with status 501. + */ +export class PoolManager { + private readonly pools: Pools; + private readonly connectionConfig: ConnectionConfig; + + private constructor(opts: { pools: Pools; connectionConfig: ConnectionConfig }) { + this.pools = opts.pools; + this.connectionConfig = opts.connectionConfig; + } + + /** + * Create a PoolManager with the provided (or default) connection config. + */ + static create(opts: PoolManagerOptions = {}): PoolManager { + const baseConfig = + opts.connectionConfig instanceof ConnectionConfig + ? opts.connectionConfig + : new ConnectionConfig(opts.connectionConfig); + const connectionConfig = baseConfig.withTransportIfMissing(); + + const pools = new PoolsAdapter({ + baseUrl: connectionConfig.getBaseUrl(), + apiKey: connectionConfig.apiKey, + headers: connectionConfig.headers, + fetch: connectionConfig.fetch, + }); + + return new PoolManager({ pools, connectionConfig }); + } + + // -------------------------------------------------------------------------- + // Pool CRUD + // -------------------------------------------------------------------------- + + /** + * Create a new pre-warmed resource pool. + * + * @param req - Pool creation parameters. + * @returns The newly created pool. + */ + createPool(req: CreatePoolRequest): Promise { + return this.pools.createPool(req); + } + + /** + * Retrieve a pool by name. + * + * @param poolName - Pool name to look up. + * @returns Current pool state including observed runtime status. + */ + getPool(poolName: string): Promise { + return this.pools.getPool(poolName); + } + + /** + * List all pools in the namespace. + * + * @returns All pools. + */ + listPools(): Promise { + return this.pools.listPools(); + } + + /** + * Update the capacity configuration of an existing pool. + * + * Only `capacitySpec` can be changed after pool creation. + * To change the pod template, delete and recreate the pool. + * + * @param poolName - Name of the pool to update. + * @param req - New capacity configuration. + * @returns Updated pool state. + */ + updatePool(poolName: string, req: UpdatePoolRequest): Promise { + return this.pools.updatePool(poolName, req); + } + + /** + * Delete a pool. + * + * @param poolName - Name of the pool to delete. + */ + deletePool(poolName: string): Promise { + return this.pools.deletePool(poolName); + } + + // -------------------------------------------------------------------------- + // Lifecycle + // -------------------------------------------------------------------------- + + /** + * Release the HTTP transport resources owned by this manager. + */ + async close(): Promise { + await this.connectionConfig.closeTransport(); + } +} diff --git a/sdks/sandbox/javascript/src/services/pools.ts b/sdks/sandbox/javascript/src/services/pools.ts new file mode 100644 index 00000000..fd603a0a --- /dev/null +++ b/sdks/sandbox/javascript/src/services/pools.ts @@ -0,0 +1,71 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { + CreatePoolRequest, + PoolInfo, + PoolListResponse, + UpdatePoolRequest, +} from "../models/pools.js"; + +/** + * Service interface for managing pre-warmed sandbox resource pools. + * + * Abstracting over the concrete adapter implementation keeps PoolManager + * and tests decoupled from HTTP transport details. + */ +export interface Pools { + /** + * Create a new pre-warmed resource pool. + * + * @param req - Pool creation parameters. + * @returns The newly created pool. + * @throws {@link SandboxApiException} on server errors. + */ + createPool(req: CreatePoolRequest): Promise; + + /** + * Retrieve a pool by name. + * + * @param poolName - Name of the pool. + * @returns Current pool state including observed runtime status. + * @throws {@link SandboxApiException} with status 404 if not found. + */ + getPool(poolName: string): Promise; + + /** + * List all pools in the namespace. + * + * @returns All pools. + */ + listPools(): Promise; + + /** + * Update the capacity configuration of an existing pool. + * + * @param poolName - Name of the pool to update. + * @param req - New capacity configuration. + * @returns Updated pool state. + * @throws {@link SandboxApiException} with status 404 if not found. + */ + updatePool(poolName: string, req: UpdatePoolRequest): Promise; + + /** + * Delete a pool. + * + * @param poolName - Name of the pool to delete. + * @throws {@link SandboxApiException} with status 404 if not found. + */ + deletePool(poolName: string): Promise; +} diff --git a/sdks/sandbox/javascript/tests/pool.manager.test.ts b/sdks/sandbox/javascript/tests/pool.manager.test.ts new file mode 100644 index 00000000..c0f05a86 --- /dev/null +++ b/sdks/sandbox/javascript/tests/pool.manager.test.ts @@ -0,0 +1,298 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Tests for PoolManager – business logic, delegation to service, lifecycle. + */ + +import { describe, it, expect, vi } from "vitest"; +import { PoolManager } from "../src/poolManager.js"; +import { SandboxApiException } from "../src/core/exceptions.js"; +import type { Pools } from "../src/services/pools.js"; +import type { PoolInfo, PoolListResponse } from "../src/models/pools.js"; + +// --------------------------------------------------------------------------- +// Stub Pools service +// --------------------------------------------------------------------------- + +function makePoolInfo(name: string, bufferMax = 3, poolMax = 10): PoolInfo { + return { + name, + capacitySpec: { bufferMax, bufferMin: 1, poolMax, poolMin: 0 }, + status: { total: 0, allocated: 0, available: 0, revision: "init" }, + }; +} + +class PoolsStub implements Pools { + private store = new Map(); + readonly createCalls: import("../src/models/pools.js").CreatePoolRequest[] = []; + readonly updateCalls: Array<{ poolName: string; req: import("../src/models/pools.js").UpdatePoolRequest }> = []; + readonly deleteCalls: string[] = []; + + async createPool(req: import("../src/models/pools.js").CreatePoolRequest): Promise { + this.createCalls.push(req); + const info = makePoolInfo(req.name, req.capacitySpec.bufferMax, req.capacitySpec.poolMax); + this.store.set(req.name, info); + return info; + } + + async getPool(poolName: string): Promise { + const info = this.store.get(poolName); + if (!info) throw new SandboxApiException({ message: `Pool '${poolName}' not found.`, statusCode: 404 }); + return info; + } + + async listPools(): Promise { + return { items: Array.from(this.store.values()) }; + } + + async updatePool(poolName: string, req: import("../src/models/pools.js").UpdatePoolRequest): Promise { + this.updateCalls.push({ poolName, req }); + if (!this.store.has(poolName)) { + throw new SandboxApiException({ message: `Pool '${poolName}' not found.`, statusCode: 404 }); + } + const updated = makePoolInfo(poolName, req.capacitySpec.bufferMax, req.capacitySpec.poolMax); + this.store.set(poolName, updated); + return updated; + } + + async deletePool(poolName: string): Promise { + this.deleteCalls.push(poolName); + if (!this.store.has(poolName)) { + throw new SandboxApiException({ message: `Pool '${poolName}' not found.`, statusCode: 404 }); + } + this.store.delete(poolName); + } +} + +function makeManager(): { manager: PoolManager; stub: PoolsStub } { + const stub = new PoolsStub(); + // Inject stub via the private constructor accessor + const manager = Object.create(PoolManager.prototype) as PoolManager; + (manager as any).pools = stub; + (manager as any).connectionConfig = { closeTransport: async () => {} }; + return { manager, stub }; +} + +// --------------------------------------------------------------------------- +// createPool +// --------------------------------------------------------------------------- + +describe("PoolManager.createPool", () => { + it("returns PoolInfo with correct fields", async () => { + const { manager } = makeManager(); + const pool = await manager.createPool({ + name: "ci-pool", + template: { spec: {} }, + capacitySpec: { bufferMax: 3, bufferMin: 1, poolMax: 10, poolMin: 0 }, + }); + expect(pool.name).toBe("ci-pool"); + expect(pool.capacitySpec.bufferMax).toBe(3); + expect(pool.capacitySpec.poolMax).toBe(10); + }); + + it("delegates to Pools service with the full request", async () => { + const { manager, stub } = makeManager(); + await manager.createPool({ + name: "my-pool", + template: { spec: { containers: [] } }, + capacitySpec: { bufferMax: 5, bufferMin: 2, poolMax: 20, poolMin: 1 }, + }); + expect(stub.createCalls).toHaveLength(1); + const req = stub.createCalls[0]; + expect(req.name).toBe("my-pool"); + expect(req.capacitySpec.bufferMax).toBe(5); + expect(req.capacitySpec.poolMin).toBe(1); + }); + + it("propagates SandboxApiException on 409", async () => { + const { manager, stub } = makeManager(); + stub.createPool = vi.fn().mockRejectedValue( + new SandboxApiException({ message: "already exists", statusCode: 409 }) + ); + await expect(manager.createPool({ + name: "dup", template: {}, + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); +}); + +// --------------------------------------------------------------------------- +// getPool +// --------------------------------------------------------------------------- + +describe("PoolManager.getPool", () => { + it("returns existing pool info", async () => { + const { manager } = makeManager(); + await manager.createPool({ + name: "p1", template: {}, + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + }); + const pool = await manager.getPool("p1"); + expect(pool.name).toBe("p1"); + }); + + it("throws SandboxApiException with 404 for missing pool", async () => { + const { manager } = makeManager(); + const err = await manager.getPool("ghost").catch(e => e); + expect(err).toBeInstanceOf(SandboxApiException); + expect((err as SandboxApiException).statusCode).toBe(404); + }); + + it("delegates with correct pool name", async () => { + const { manager, stub } = makeManager(); + stub.getPool = vi.fn().mockResolvedValue(makePoolInfo("target")); + await manager.getPool("target"); + expect(stub.getPool).toHaveBeenCalledWith("target"); + }); +}); + +// --------------------------------------------------------------------------- +// listPools +// --------------------------------------------------------------------------- + +describe("PoolManager.listPools", () => { + it("returns empty list when no pools", async () => { + const { manager } = makeManager(); + const result = await manager.listPools(); + expect(result.items).toHaveLength(0); + }); + + it("returns all pools", async () => { + const { manager } = makeManager(); + await manager.createPool({ name: "a", template: {}, capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 } }); + await manager.createPool({ name: "b", template: {}, capacitySpec: { bufferMax: 2, bufferMin: 1, poolMax: 8, poolMin: 0 } }); + const result = await manager.listPools(); + expect(result.items).toHaveLength(2); + expect(result.items.map(p => p.name)).toContain("a"); + expect(result.items.map(p => p.name)).toContain("b"); + }); + + it("delegates to Pools service", async () => { + const { manager, stub } = makeManager(); + stub.listPools = vi.fn().mockResolvedValue({ items: [] }); + await manager.listPools(); + expect(stub.listPools).toHaveBeenCalledOnce(); + }); +}); + +// --------------------------------------------------------------------------- +// updatePool +// --------------------------------------------------------------------------- + +describe("PoolManager.updatePool", () => { + it("updates capacity and returns new PoolInfo", async () => { + const { manager } = makeManager(); + await manager.createPool({ name: "p", template: {}, capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 } }); + + const updated = await manager.updatePool("p", { + capacitySpec: { bufferMax: 9, bufferMin: 3, poolMax: 50, poolMin: 0 }, + }); + expect(updated.capacitySpec.bufferMax).toBe(9); + expect(updated.capacitySpec.poolMax).toBe(50); + }); + + it("delegates with correct poolName and request", async () => { + const { manager, stub } = makeManager(); + await manager.createPool({ name: "p", template: {}, capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 } }); + + await manager.updatePool("p", { + capacitySpec: { bufferMax: 7, bufferMin: 2, poolMax: 30, poolMin: 0 }, + }); + expect(stub.updateCalls).toHaveLength(1); + expect(stub.updateCalls[0].poolName).toBe("p"); + expect(stub.updateCalls[0].req.capacitySpec.bufferMax).toBe(7); + }); + + it("throws SandboxApiException on 404 for missing pool", async () => { + const { manager } = makeManager(); + await expect(manager.updatePool("ghost", { + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); +}); + +// --------------------------------------------------------------------------- +// deletePool +// --------------------------------------------------------------------------- + +describe("PoolManager.deletePool", () => { + it("successfully deletes an existing pool", async () => { + const { manager, stub } = makeManager(); + await manager.createPool({ name: "bye", template: {}, capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 } }); + await manager.deletePool("bye"); + expect(stub.deleteCalls).toContain("bye"); + expect(stub["store"].has("bye")).toBe(false); + }); + + it("delegates with correct pool name", async () => { + const { manager, stub } = makeManager(); + stub.deletePool = vi.fn().mockResolvedValue(undefined); + await manager.deletePool("to-delete"); + expect(stub.deletePool).toHaveBeenCalledWith("to-delete"); + }); + + it("throws SandboxApiException on 404 for missing pool", async () => { + const { manager } = makeManager(); + await expect(manager.deletePool("ghost")).rejects.toThrow(SandboxApiException); + }); + + it("resolves to undefined on success", async () => { + const { manager } = makeManager(); + await manager.createPool({ name: "x", template: {}, capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 } }); + await expect(manager.deletePool("x")).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// PoolManager.create factory +// --------------------------------------------------------------------------- + +describe("PoolManager.create", () => { + it("creates a PoolManager instance", () => { + const manager = PoolManager.create({ + connectionConfig: { apiKey: "test-key", baseUrl: "http://localhost:8080" }, + }); + expect(manager).toBeInstanceOf(PoolManager); + }); + + it("creates with default options", () => { + const manager = PoolManager.create(); + expect(manager).toBeInstanceOf(PoolManager); + }); + + it("exposes createPool, getPool, listPools, updatePool, deletePool methods", () => { + const manager = PoolManager.create(); + expect(typeof manager.createPool).toBe("function"); + expect(typeof manager.getPool).toBe("function"); + expect(typeof manager.listPools).toBe("function"); + expect(typeof manager.updatePool).toBe("function"); + expect(typeof manager.deletePool).toBe("function"); + expect(typeof manager.close).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// close +// --------------------------------------------------------------------------- + +describe("PoolManager.close", () => { + it("calls closeTransport on the connectionConfig", async () => { + const { manager } = makeManager(); + const closeSpy = vi.fn().mockResolvedValue(undefined); + (manager as any).connectionConfig = { closeTransport: closeSpy }; + await manager.close(); + expect(closeSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/sdks/sandbox/javascript/tests/pools.adapter.test.ts b/sdks/sandbox/javascript/tests/pools.adapter.test.ts new file mode 100644 index 00000000..01a7ac77 --- /dev/null +++ b/sdks/sandbox/javascript/tests/pools.adapter.test.ts @@ -0,0 +1,387 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Tests for PoolsAdapter – HTTP call behaviour, correct URL/method routing, + * request body construction, error propagation. + */ + +import { describe, it, expect, vi } from "vitest"; +import { PoolsAdapter } from "../src/adapters/poolsAdapter.js"; +import { SandboxApiException } from "../src/core/exceptions.js"; +import type { PoolComponents } from "../src/api/lifecycle.js"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +interface CapturedRequest { + url: string; + method: string; + body?: unknown; + headers: Record; +} + +function makeFetch( + status: number, + body: unknown, + capture?: { requests: CapturedRequest[] } +): typeof fetch { + return async (input, init) => { + // openapi-fetch may pass a Request object as `input` with method/headers baked in + let url: string; + let method: string; + let bodyStr: string | null = null; + const headers: Record = {}; + + if (input instanceof Request) { + url = input.url; + method = (input.method ?? "GET").toUpperCase(); + bodyStr = await input.text().catch(() => null); + input.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; }); + } else { + url = typeof input === "string" ? input : String(input); + method = ((init?.method ?? "GET") as string).toUpperCase(); + if (init?.body) { + try { bodyStr = init.body as string; } catch { /* ignore */ } + } + if (init?.headers) { + const h = init.headers as Record; + for (const [k, v] of Object.entries(h)) { + headers[k.toLowerCase()] = v; + } + } + } + + let parsedBody: unknown; + if (bodyStr) { + try { parsedBody = JSON.parse(bodyStr); } catch { parsedBody = bodyStr; } + } + + capture?.requests.push({ url, method, body: parsedBody, headers }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +function makePoolResponse( + name = "test-pool", + bufferMax = 3, + poolMax = 10 +): PoolComponents["schemas"]["PoolResponse"] { + return { + name, + capacitySpec: { bufferMax, bufferMin: 1, poolMax, poolMin: 0 }, + status: { total: 2, allocated: 1, available: 1, revision: "rev-1" }, + createdAt: "2025-06-01T00:00:00.000Z", + }; +} + +function makeAdapter(status: number, body: unknown, capture?: { requests: CapturedRequest[] }): PoolsAdapter { + return new PoolsAdapter({ + baseUrl: "http://server.local/v1", + apiKey: "key-123", + fetch: makeFetch(status, body, capture) as unknown as typeof fetch, + }); +} + +// --------------------------------------------------------------------------- +// createPool +// --------------------------------------------------------------------------- + +describe("PoolsAdapter.createPool", () => { + it("sends POST to /pools", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const adapter = makeAdapter(201, makePoolResponse("new-pool"), captured); + + await adapter.createPool({ + name: "new-pool", + template: { spec: {} }, + capacitySpec: { bufferMax: 3, bufferMin: 1, poolMax: 10, poolMin: 0 }, + }); + + expect(captured.requests).toHaveLength(1); + const req = captured.requests[0]; + expect(req.method).toBe("POST"); + expect(req.url).toContain("/pools"); + expect(req.url).not.toContain("/pools/"); + }); + + it("sends correct request body", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const adapter = makeAdapter(201, makePoolResponse(), captured); + + await adapter.createPool({ + name: "ci-pool", + template: { spec: { containers: [{ name: "sbx" }] } }, + capacitySpec: { bufferMax: 5, bufferMin: 2, poolMax: 20, poolMin: 1 }, + }); + + const body = captured.requests[0].body as Record; + expect(body.name).toBe("ci-pool"); + const cap = body.capacitySpec as Record; + expect(cap.bufferMax).toBe(5); + expect(cap.poolMax).toBe(20); + expect(cap.poolMin).toBe(1); + }); + + it("returns mapped PoolInfo", async () => { + const adapter = makeAdapter(201, makePoolResponse("created", 3, 10)); + const result = await adapter.createPool({ + name: "created", + template: {}, + capacitySpec: { bufferMax: 3, bufferMin: 1, poolMax: 10, poolMin: 0 }, + }); + + expect(result.name).toBe("created"); + expect(result.capacitySpec.bufferMax).toBe(3); + expect(result.status?.total).toBe(2); + expect(result.createdAt).toBeInstanceOf(Date); + }); + + it("includes API key header", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const adapter = makeAdapter(201, makePoolResponse(), captured); + await adapter.createPool({ + name: "p", template: {}, + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + }); + expect(captured.requests[0].headers["open-sandbox-api-key"]).toBe("key-123"); + }); + + it("throws SandboxApiException on 409 conflict", async () => { + const adapter = makeAdapter(409, { code: "POOL_ALREADY_EXISTS", message: "already exists" }); + await expect(adapter.createPool({ + name: "dup", template: {}, + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); + + it("throws SandboxApiException on 400 bad request", async () => { + const adapter = makeAdapter(400, { code: "INVALID_REQUEST", message: "bad" }); + await expect(adapter.createPool({ + name: "bad", template: {}, + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); + + it("throws SandboxApiException on 501 not supported", async () => { + const adapter = makeAdapter(501, { code: "NOT_SUPPORTED", message: "non-k8s" }); + await expect(adapter.createPool({ + name: "p", template: {}, + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); + + it("throws SandboxApiException on 500 server error", async () => { + const adapter = makeAdapter(500, { code: "INTERNAL", message: "error" }); + await expect(adapter.createPool({ + name: "p", template: {}, + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); +}); + +// --------------------------------------------------------------------------- +// getPool +// --------------------------------------------------------------------------- + +describe("PoolsAdapter.getPool", () => { + it("sends GET to /pools/{poolName}", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const adapter = makeAdapter(200, makePoolResponse("my-pool"), captured); + + await adapter.getPool("my-pool"); + + const req = captured.requests[0]; + expect(req.method).toBe("GET"); + expect(req.url).toContain("/pools/my-pool"); + }); + + it("returns mapped PoolInfo", async () => { + const adapter = makeAdapter(200, makePoolResponse("my-pool", 2, 8)); + const result = await adapter.getPool("my-pool"); + expect(result.name).toBe("my-pool"); + expect(result.capacitySpec.poolMax).toBe(8); + }); + + it("throws SandboxApiException on 404", async () => { + const adapter = makeAdapter(404, { code: "NOT_FOUND", message: "not found" }); + await expect(adapter.getPool("ghost")).rejects.toThrow(SandboxApiException); + }); + + it("throws SandboxApiException on 500", async () => { + const adapter = makeAdapter(500, { code: "INTERNAL", message: "err" }); + await expect(adapter.getPool("p")).rejects.toThrow(SandboxApiException); + }); +}); + +// --------------------------------------------------------------------------- +// listPools +// --------------------------------------------------------------------------- + +describe("PoolsAdapter.listPools", () => { + it("sends GET to /pools (no path segment)", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const listBody: PoolComponents["schemas"]["ListPoolsResponse"] = { + items: [makePoolResponse("p1"), makePoolResponse("p2")], + }; + const adapter = makeAdapter(200, listBody, captured); + + await adapter.listPools(); + + const req = captured.requests[0]; + expect(req.method).toBe("GET"); + // URL should end in /pools (not /pools/something) + expect(req.url).toMatch(/\/pools\/?$/); + }); + + it("returns all items", async () => { + const listBody: PoolComponents["schemas"]["ListPoolsResponse"] = { + items: [makePoolResponse("a"), makePoolResponse("b"), makePoolResponse("c")], + }; + const adapter = makeAdapter(200, listBody); + const result = await adapter.listPools(); + + expect(result.items).toHaveLength(3); + expect(result.items.map(p => p.name)).toEqual(["a", "b", "c"]); + }); + + it("returns empty array for empty list", async () => { + const adapter = makeAdapter(200, { items: [] }); + const result = await adapter.listPools(); + expect(result.items).toHaveLength(0); + }); + + it("throws SandboxApiException on 500", async () => { + const adapter = makeAdapter(500, { code: "INTERNAL", message: "err" }); + await expect(adapter.listPools()).rejects.toThrow(SandboxApiException); + }); + + it("throws SandboxApiException on 501", async () => { + const adapter = makeAdapter(501, { code: "NOT_SUPPORTED", message: "non-k8s" }); + await expect(adapter.listPools()).rejects.toThrow(SandboxApiException); + }); +}); + +// --------------------------------------------------------------------------- +// updatePool +// --------------------------------------------------------------------------- + +describe("PoolsAdapter.updatePool", () => { + it("sends PUT to /pools/{poolName}", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const adapter = makeAdapter(200, makePoolResponse("target", 9, 50), captured); + + await adapter.updatePool("target", { + capacitySpec: { bufferMax: 9, bufferMin: 3, poolMax: 50, poolMin: 0 }, + }); + + const req = captured.requests[0]; + expect(req.method).toBe("PUT"); + expect(req.url).toContain("/pools/target"); + }); + + it("sends correct capacity in request body", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const adapter = makeAdapter(200, makePoolResponse(), captured); + + await adapter.updatePool("p", { + capacitySpec: { bufferMax: 7, bufferMin: 3, poolMax: 30, poolMin: 2 }, + }); + + const body = captured.requests[0].body as Record; + const cap = body.capacitySpec as Record; + expect(cap.bufferMax).toBe(7); + expect(cap.poolMin).toBe(2); + }); + + it("returns updated PoolInfo", async () => { + const adapter = makeAdapter(200, makePoolResponse("p", 9, 50)); + const result = await adapter.updatePool("p", { + capacitySpec: { bufferMax: 9, bufferMin: 3, poolMax: 50, poolMin: 0 }, + }); + expect(result.capacitySpec.bufferMax).toBe(9); + expect(result.capacitySpec.poolMax).toBe(50); + }); + + it("throws SandboxApiException on 404", async () => { + const adapter = makeAdapter(404, { code: "NOT_FOUND", message: "not found" }); + await expect(adapter.updatePool("ghost", { + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); + + it("throws SandboxApiException on 500", async () => { + const adapter = makeAdapter(500, { code: "INTERNAL", message: "err" }); + await expect(adapter.updatePool("p", { + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + })).rejects.toThrow(SandboxApiException); + }); +}); + +// --------------------------------------------------------------------------- +// deletePool +// --------------------------------------------------------------------------- + +describe("PoolsAdapter.deletePool", () => { + it("sends DELETE to /pools/{poolName}", async () => { + const captured = { requests: [] as CapturedRequest[] }; + const adapter = new PoolsAdapter({ + baseUrl: "http://server.local/v1", + apiKey: "key-123", + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + let url: string; + let method: string; + if (input instanceof Request) { + url = input.url; + method = (input.method ?? "GET").toUpperCase(); + } else { + url = typeof input === "string" ? input : String(input); + method = ((init?.method ?? "GET") as string).toUpperCase(); + } + captured.requests.push({ url, method, headers: {} }); + return new Response(null, { status: 204 }); + }) as unknown as typeof fetch, + }); + + await adapter.deletePool("bye-pool"); + + expect(captured.requests).toHaveLength(1); + const req = captured.requests[0]; + expect(req.method).toBe("DELETE"); + expect(req.url).toContain("/pools/bye-pool"); + }); + + it("resolves (returns undefined) on success", async () => { + const adapter = new PoolsAdapter({ + baseUrl: "http://server.local/v1", + apiKey: "key-123", + fetch: (async () => new Response(null, { status: 204 })) as unknown as typeof fetch, + }); + await expect(adapter.deletePool("p")).resolves.toBeUndefined(); + }); + + it("throws SandboxApiException on 404", async () => { + const adapter = makeAdapter(404, { code: "NOT_FOUND", message: "not found" }); + await expect(adapter.deletePool("ghost")).rejects.toThrow(SandboxApiException); + }); + + it("throws SandboxApiException on 500", async () => { + const adapter = makeAdapter(500, { code: "INTERNAL", message: "err" }); + await expect(adapter.deletePool("p")).rejects.toThrow(SandboxApiException); + }); +}); diff --git a/sdks/sandbox/javascript/tests/pools.models.test.ts b/sdks/sandbox/javascript/tests/pools.models.test.ts new file mode 100644 index 00000000..3342417a --- /dev/null +++ b/sdks/sandbox/javascript/tests/pools.models.test.ts @@ -0,0 +1,207 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Tests for Pool domain model types and PoolsAdapter mapping helpers. + * + * These tests exercise the adapter's internal mapping functions + * without making any HTTP calls. + */ + +import { describe, it, expect } from "vitest"; +import { PoolsAdapter } from "../src/adapters/poolsAdapter.js"; +import type { PoolComponents } from "../src/api/lifecycle.js"; + +// ---- helpers to build raw API payloads ----- + +function makeRawCapacity( + overrides: Partial = {} +): PoolComponents["schemas"]["PoolCapacitySpec"] { + return { bufferMax: 3, bufferMin: 1, poolMax: 10, poolMin: 0, ...overrides }; +} + +function makeRawStatus( + overrides: Partial = {} +): PoolComponents["schemas"]["PoolStatus"] { + return { total: 2, allocated: 1, available: 1, revision: "rev-1", ...overrides }; +} + +function makeRawPool( + name = "test-pool", + withStatus = true, + withCreatedAt = true +): PoolComponents["schemas"]["PoolResponse"] { + return { + name, + capacitySpec: makeRawCapacity(), + status: withStatus ? makeRawStatus() : undefined, + createdAt: withCreatedAt ? "2025-01-01T00:00:00.000Z" : undefined, + }; +} + +// --------------------------------------------------------------------------- +// model types +// --------------------------------------------------------------------------- + +describe("Pool model shape", () => { + it("PoolCapacitySpec has expected fields", () => { + // Compile-time verification: if a field is wrong this file won't compile. + const spec: import("../src/models/pools.js").PoolCapacitySpec = { + bufferMax: 3, + bufferMin: 1, + poolMax: 10, + poolMin: 0, + }; + expect(spec.bufferMax).toBe(3); + expect(spec.poolMax).toBe(10); + }); + + it("PoolInfo allows optional status and createdAt", () => { + const info: import("../src/models/pools.js").PoolInfo = { + name: "p", + capacitySpec: { bufferMax: 1, bufferMin: 0, poolMax: 5, poolMin: 0 }, + }; + expect(info.status).toBeUndefined(); + expect(info.createdAt).toBeUndefined(); + }); + + it("PoolListResponse items array", () => { + const resp: import("../src/models/pools.js").PoolListResponse = { items: [] }; + expect(resp.items).toHaveLength(0); + }); + + it("CreatePoolRequest has name, template, capacitySpec", () => { + const req: import("../src/models/pools.js").CreatePoolRequest = { + name: "my-pool", + template: { spec: {} }, + capacitySpec: { bufferMax: 2, bufferMin: 1, poolMax: 8, poolMin: 0 }, + }; + expect(req.name).toBe("my-pool"); + }); + + it("UpdatePoolRequest has capacitySpec", () => { + const req: import("../src/models/pools.js").UpdatePoolRequest = { + capacitySpec: { bufferMax: 5, bufferMin: 2, poolMax: 20, poolMin: 0 }, + }; + expect(req.capacitySpec.poolMax).toBe(20); + }); +}); + +// --------------------------------------------------------------------------- +// PoolsAdapter mapping helpers (via createPool / getPool / listPools which +// go through the same internal mapPoolInfo helper) +// --------------------------------------------------------------------------- + +describe("PoolsAdapter – internal mapping", () => { + // We access the private static-like helpers by making a real adapter with + // a mocked fetch, then checking the returned domain model shapes. + + function makeMockFetch(status: number, body: unknown): typeof fetch { + return async (_input, _init) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + + function makeAdapter(status: number, body: unknown): PoolsAdapter { + return new PoolsAdapter({ + baseUrl: "http://test.local/v1", + apiKey: "test-key", + fetch: makeMockFetch(status, body) as unknown as typeof fetch, + }); + } + + describe("capacitySpec mapping", () => { + it("maps all four capacity fields correctly", async () => { + const raw = makeRawPool(); + const adapter = makeAdapter(201, raw); + const result = await adapter.createPool({ + name: "test-pool", + template: {}, + capacitySpec: { bufferMax: 3, bufferMin: 1, poolMax: 10, poolMin: 0 }, + }); + expect(result.capacitySpec.bufferMax).toBe(3); + expect(result.capacitySpec.bufferMin).toBe(1); + expect(result.capacitySpec.poolMax).toBe(10); + expect(result.capacitySpec.poolMin).toBe(0); + }); + }); + + describe("status mapping", () => { + it("maps status fields when present", async () => { + const raw = makeRawPool("p", true); + const adapter = makeAdapter(200, raw); + const result = await adapter.getPool("p"); + expect(result.status).toBeDefined(); + expect(result.status?.total).toBe(2); + expect(result.status?.allocated).toBe(1); + expect(result.status?.available).toBe(1); + expect(result.status?.revision).toBe("rev-1"); + }); + + it("status is undefined when absent in response", async () => { + const raw = makeRawPool("p", false); + const adapter = makeAdapter(200, raw); + const result = await adapter.getPool("p"); + expect(result.status).toBeUndefined(); + }); + }); + + describe("createdAt mapping", () => { + it("parses ISO createdAt to Date", async () => { + const raw = makeRawPool("p", true, true); + const adapter = makeAdapter(200, raw); + const result = await adapter.getPool("p"); + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.createdAt?.getFullYear()).toBe(2025); + }); + + it("createdAt is undefined when absent in response", async () => { + const raw = makeRawPool("p", true, false); + const adapter = makeAdapter(200, raw); + const result = await adapter.getPool("p"); + expect(result.createdAt).toBeUndefined(); + }); + }); + + describe("name mapping", () => { + it("preserves pool name", async () => { + const adapter = makeAdapter(200, makeRawPool("my-special-pool")); + const result = await adapter.getPool("my-special-pool"); + expect(result.name).toBe("my-special-pool"); + }); + }); + + describe("listPools item mapping", () => { + it("maps each item in list response", async () => { + const listBody: PoolComponents["schemas"]["ListPoolsResponse"] = { + items: [makeRawPool("pool-a"), makeRawPool("pool-b", false, false)], + }; + const adapter = makeAdapter(200, listBody); + const result = await adapter.listPools(); + expect(result.items).toHaveLength(2); + expect(result.items[0].name).toBe("pool-a"); + expect(result.items[1].name).toBe("pool-b"); + expect(result.items[0].status).toBeDefined(); + expect(result.items[1].status).toBeUndefined(); + }); + + it("returns empty items array for empty list", async () => { + const adapter = makeAdapter(200, { items: [] }); + const result = await adapter.listPools(); + expect(result.items).toHaveLength(0); + }); + }); +}); diff --git a/sdks/sandbox/javascript/vitest.config.ts b/sdks/sandbox/javascript/vitest.config.ts new file mode 100644 index 00000000..8a00956d --- /dev/null +++ b/sdks/sandbox/javascript/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + include: ["tests/**/*.test.ts"], + }, +});