diff --git a/.github/workflows/sdk-coverage.yml b/.github/workflows/sdk-coverage.yml index 2d6fb9e3d..653c9ff0a 100644 --- a/.github/workflows/sdk-coverage.yml +++ b/.github/workflows/sdk-coverage.yml @@ -54,7 +54,9 @@ jobs: - name: Run smoke tests with coverage id: tests continue-on-error: true - run: yarn test:objects-coverage 2>&1 | tee test-output.log + run: | + set -o pipefail + yarn test:objects-coverage 2>&1 | tee test-output.log - name: Upload coverage report uses: runloopai/upload-artifact@main diff --git a/src/index.ts b/src/index.ts index 1a0e5f455..7140ff6d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -756,6 +756,7 @@ export declare namespace Runloop { export type RunProfile = API.RunProfile; } +export { type LongPollRequestOptions, LongPollAbortError, PollingTimeoutError } from './lib/polling'; export { toFile, fileFromPath } from './uploads'; export { RunloopError, diff --git a/src/lib/devbox-state.ts b/src/lib/devbox-state.ts index 3a6152557..86d91ff4f 100644 --- a/src/lib/devbox-state.ts +++ b/src/lib/devbox-state.ts @@ -1,4 +1,4 @@ -import { poll, PollingOptions } from './polling'; +import { longPollUntil } from './polling'; export interface DevboxStateWaitOptions { client: { @@ -8,45 +8,37 @@ export interface DevboxStateWaitOptions { targetState: string; statesToCheck: string[]; transitionStates: string[]; - pollingOptions?: Partial> | undefined; + /** Timeout in milliseconds for the long-poll operation. */ + timeoutMs?: number | undefined; + /** Optional AbortSignal to cancel the long-poll loop externally. */ + signal?: AbortSignal | null | undefined; errorMessage: (id: string, actualState: string) => string; } /** * Shared utility for waiting for a devbox to reach a specific state. - * Uses the /wait_for_status endpoint with polling. + * Uses the /wait_for_status long-poll endpoint. */ export async function awaitDevboxState( options: DevboxStateWaitOptions, ): Promise { - const { client, devboxId, targetState, statesToCheck, transitionStates, pollingOptions, errorMessage } = - options; + const { client, devboxId, targetState, statesToCheck, transitionStates, errorMessage } = options; - const longPoll = (): Promise => { - // This either returns a DevboxView when status matches one of statesToCheck; - // Otherwise it throws an 408 error when times out. - return client.post(`/v1/devboxes/${devboxId}/wait_for_status`, { - body: { statuses: statesToCheck }, - }); - }; - - const finalResult = await poll( - () => longPoll(), - () => longPoll(), + const finalResult = await longPollUntil( + (signal) => + client.post(`/v1/devboxes/${devboxId}/wait_for_status`, { + body: { statuses: statesToCheck }, + signal, + // Per-request HTTP timeout must exceed the server's max long-poll hold (30s) + // so the server's 408 always arrives before the client aborts the connection. + // The longPollUntil AbortSignal enforces the caller's actual deadline. + timeout: 600000, + maxRetries: 0, + }), { - ...pollingOptions, - shouldStop: (result) => { - return !transitionStates.includes(result.status); - }, - onError: (error: any) => { - if (error.status === 408) { - // Return a placeholder result to continue polling - return { status: transitionStates[0] } as T; - } - - // For any other error, rethrow it - throw error; - }, + timeoutMs: options.timeoutMs, + shouldStop: (result) => !transitionStates.includes(result.status), + signal: options.signal, }, ); diff --git a/src/lib/polling.ts b/src/lib/polling.ts index 9fd294fee..5a233f5ab 100644 --- a/src/lib/polling.ts +++ b/src/lib/polling.ts @@ -1,11 +1,12 @@ import { APIError } from '../error'; +import type * as Core from '../core'; export interface PollingOptions { - /** Initial delay before starting polling (in milliseconds) */ + /** Delay after the initial request completes before the first poll iteration (in milliseconds) */ initialDelayMs?: number; /** Delay between subsequent polling attempts (in milliseconds) */ pollingIntervalMs?: number; - /** Maximum number of polling attempts before throwing an error. Defaults to infinite (no limit). */ + /** Maximum number of polling attempts before throwing an error. 0 means no polling (initial request only). Defaults to infinite (no limit). */ maxAttempts?: number; /** Optional timeout for the entire polling operation (in milliseconds) */ timeoutMs?: number; @@ -48,6 +49,152 @@ export class MaxAttemptsExceededError extends Error { } } +export interface LongPollOptions { + /** Optional timeout for the entire long-poll operation (in milliseconds). Enforced mid-request via Promise.race. */ + timeoutMs?: number | undefined; + /** Condition to check if long-polling should stop. Return true when done. */ + shouldStop: (result: T) => boolean; + /** + * Optional callback for each successful long-poll attempt. + * The attempt counter includes 408 retries, but this callback is only + * invoked when the server returns a non-error response. + */ + onAttempt?: ((attempt: number, result: T) => void) | undefined; + /** Optional AbortSignal to cancel the long-poll loop externally. */ + signal?: AbortSignal | null | undefined; +} + +/** + * Request options for methods that use server-side long-polling. + * Extends `Core.RequestOptions` with long-poll configuration alongside + * the deprecated `polling` field for backwards compatibility. + */ +export type LongPollRequestOptions = Core.RequestOptions & { + /** Options for the long-poll operation. */ + longPoll?: + | { + /** Timeout in milliseconds for the entire long-poll operation. */ + timeoutMs?: number; + } + | undefined; + /** @deprecated Use `longPoll` instead. Only `timeoutMs` is extracted; other fields are ignored for long-poll endpoints. */ + polling?: Partial> | undefined; +}; + +const DEPRECATED_POLLING_FIELDS = ['maxAttempts', 'pollingIntervalMs', 'initialDelayMs'] as const; +let _warnedDeprecatedPolling = false; + +/** + * Resolve the effective `timeoutMs` from {@link LongPollRequestOptions}, + * preferring `longPoll.timeoutMs` over the deprecated `polling.timeoutMs`. + * + * Emits a one-time `console.warn` when callers supply deprecated polling + * fields that are silently ignored by long-poll endpoints. + */ +export function resolveLongPollTimeoutMs(options?: LongPollRequestOptions): number | undefined { + if (options?.polling && !_warnedDeprecatedPolling) { + const ignored = DEPRECATED_POLLING_FIELDS.filter( + (f) => (options.polling as Record)?.[f] !== undefined, + ); + if (ignored.length > 0) { + console.warn( + `[runloop-api-client] polling options { ${ignored.join(', ')} } are ignored for long-poll endpoints. ` + + `Only \`timeoutMs\` is honoured. Migrate to \`longPoll: { timeoutMs }\` instead.`, + ); + _warnedDeprecatedPolling = true; + } else if (options.polling.timeoutMs !== undefined && !options.longPoll?.timeoutMs) { + console.warn( + '[runloop-api-client] `polling: { timeoutMs }` is deprecated. Use `longPoll: { timeoutMs }` instead.', + ); + _warnedDeprecatedPolling = true; + } + } + return options?.longPoll?.timeoutMs ?? options?.polling?.timeoutMs; +} + +/** @internal Resets the deprecation warning flag — only for tests. */ +export function _resetDeprecationWarning(): void { + _warnedDeprecatedPolling = false; +} + +/** + * Long-poll loop for server-side blocking endpoints (e.g. wait_for_status). + * Retries automatically on 408 (server timeout). No sleep between attempts + * because the server already blocks. Each iteration's request is given an + * AbortSignal so that timeouts and external cancellation actually cancel the + * in-flight HTTP request rather than just racing past it. + */ +export async function longPollUntil( + request: (signal: AbortSignal) => Promise, + options: LongPollOptions, +): Promise { + const { timeoutMs, shouldStop, onAttempt, signal } = options; + + if (timeoutMs !== undefined && timeoutMs <= 0) { + throw new Error('timeoutMs must be positive'); + } + + if (signal?.aborted) { + throw new LongPollAbortError('Long poll aborted', undefined); + } + + const deadline = timeoutMs ? Date.now() + timeoutMs : undefined; + let attempts = 0; + let lastResult: T | undefined; + + while (true) { + if (signal?.aborted) { + throw new LongPollAbortError('Long poll aborted', lastResult); + } + + attempts++; + const iterationController = new AbortController(); + const iterationSignal = iterationController.signal; + + const onExternalAbort = () => iterationController.abort(); + signal?.addEventListener('abort', onExternalAbort, { once: true }); + + let deadlineTimer: ReturnType | undefined; + if (deadline) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + signal?.removeEventListener('abort', onExternalAbort); + throw new PollingTimeoutError(`Long poll timed out after ${timeoutMs}ms`, lastResult); + } + deadlineTimer = setTimeout(() => iterationController.abort(), remaining); + } + + try { + const result = await request(iterationSignal); + lastResult = result; + onAttempt?.(attempts, result); + if (shouldStop(result)) return result; + } catch (error) { + if (error instanceof APIError && error.status === 408) continue; + if (iterationSignal.aborted) { + if (signal?.aborted) { + throw new LongPollAbortError('Long poll aborted', lastResult); + } + throw new PollingTimeoutError(`Long poll timed out after ${timeoutMs}ms`, lastResult); + } + throw error; + } finally { + clearTimeout(deadlineTimer); + signal?.removeEventListener('abort', onExternalAbort); + } + } +} + +export class LongPollAbortError extends Error { + constructor( + message: string, + public lastResult: unknown, + ) { + super(message); + this.name = 'LongPollAbortError'; + } +} + /** * Delay execution for specified milliseconds */ @@ -71,28 +218,45 @@ export async function poll( ...options, }; - let result: T; + if (initialDelayMs !== undefined && initialDelayMs < 0) { + throw new Error('initialDelayMs must be non-negative'); + } + if (pollingIntervalMs !== undefined && pollingIntervalMs < 0) { + throw new Error('pollingIntervalMs must be non-negative'); + } + if (timeoutMs !== undefined && timeoutMs <= 0) { + throw new Error('timeoutMs must be positive'); + } + if (maxAttempts !== undefined && maxAttempts < 0) { + throw new Error('maxAttempts must be non-negative'); + } + + let lastResult: T | undefined; let timeoutId: NodeJS.Timeout | null = null; + + const clearTimeoutIfExists = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + const timeoutPromise = timeoutMs ? new Promise((_, reject) => { timeoutId = setTimeout(() => { - reject(new PollingTimeoutError(`Polling timed out after ${timeoutMs}ms`, result)); + reject(new PollingTimeoutError(`Polling timed out after ${timeoutMs}ms`, lastResult)); }, timeoutMs); }) : null; - const clearTimeoutIfExists = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }; + const raceTimeout = (promise: Promise): Promise => + timeoutPromise ? Promise.race([promise, timeoutPromise]) : promise; try { - // Initial request + let result: T; try { - result = await initialRequest(); + result = await raceTimeout(initialRequest()); } catch (error) { if (onError && error instanceof APIError) { result = onError(error); @@ -100,14 +264,18 @@ export async function poll( throw error; } } + lastResult = result; - // Check if we should stop after initial request if (shouldStop?.(result)) { - clearTimeoutIfExists(); return result; } - await delay(initialDelayMs!); + // maxAttempts === 0 means no polling iterations after the initial request + if (maxAttempts === 0) { + return result; + } + + await raceTimeout(delay(initialDelayMs!)); let attempts = 0; @@ -115,45 +283,33 @@ export async function poll( while (maxAttempts === undefined || attempts < maxAttempts) { ++attempts; - const pollingPromise = async () => { - try { - result = await pollingRequest(); - } catch (error) { - if (onError && error instanceof APIError) { - result = onError(error); - } else { - throw error; - } - } - - onPollingAttempt?.(attempts, result); - - if (shouldStop?.(result)) { - return result; - } - - if (maxAttempts !== undefined && attempts === maxAttempts) { - throw new MaxAttemptsExceededError(`Polling exceeded maximum attempts (${maxAttempts})`, result); + try { + result = await raceTimeout(pollingRequest()); + } catch (error) { + if (onError && error instanceof APIError) { + result = onError(error); + } else { + throw error; } + } + lastResult = result; - await delay(pollingIntervalMs!); - return null; - }; + onPollingAttempt?.(attempts, result); - // Race between polling and timeout if timeout is specified - const pollingResult = - timeoutPromise ? await Promise.race([pollingPromise(), timeoutPromise]) : await pollingPromise(); + if (shouldStop?.(result)) { + return result; + } - if (pollingResult !== null) { - clearTimeoutIfExists(); - return pollingResult as T; + if (maxAttempts !== undefined && attempts === maxAttempts) { + throw new MaxAttemptsExceededError(`Polling exceeded maximum attempts (${maxAttempts})`, result); } + + await raceTimeout(delay(pollingIntervalMs!)); } // This should only be reachable if maxAttempts is defined - throw new MaxAttemptsExceededError(`Polling exceeded maximum attempts (${maxAttempts})`, result!); - } catch (error) { + throw new MaxAttemptsExceededError(`Polling exceeded maximum attempts (${maxAttempts})`, lastResult!); + } finally { clearTimeoutIfExists(); - throw error; } } diff --git a/src/resources/devboxes/devboxes.ts b/src/resources/devboxes/devboxes.ts index be5f77320..78a2698d7 100644 --- a/src/resources/devboxes/devboxes.ts +++ b/src/resources/devboxes/devboxes.ts @@ -47,7 +47,11 @@ import { type DiskSnapshotsCursorIDPageParams, } from '../../pagination'; import { type Response } from '../../_shims/index'; -import { poll, PollingOptions } from '@runloop/api-client/lib/polling'; +import { + longPollUntil, + LongPollRequestOptions, + resolveLongPollTimeoutMs, +} from '@runloop/api-client/lib/polling'; import { awaitDevboxState } from '@runloop/api-client/lib/devbox-state'; import { DevboxTools } from './tools'; import { uuidv7 } from 'uuidv7'; @@ -90,44 +94,40 @@ export class Devboxes extends APIResource { /** * Wait for a devbox to reach the running state. - * Polls the devbox status until it reaches running state or fails with an error. + * Long Polls the devbox status until it reaches running state. * * @param id - Devbox ID - * @param options - request options to specify retries, timeout, polling, etc. + * @param options - request options with optional long-poll configuration. */ - async awaitRunning( - id: string, - options?: Core.RequestOptions & { polling?: Partial> }, - ): Promise { + async awaitRunning(id: string, options?: LongPollRequestOptions): Promise { return awaitDevboxState({ client: this._client, devboxId: id, targetState: 'running', statesToCheck: ['running', 'failure', 'shutdown'], transitionStates: DEVBOX_BOOTING_STATES, - pollingOptions: options?.polling as Partial> | undefined, + timeoutMs: resolveLongPollTimeoutMs(options), + signal: options?.signal, errorMessage: (devboxId, actualState) => `Devbox ${devboxId} is in non-running state ${actualState}`, }); } /** * Wait for a devbox to reach the suspended state. - * Polls the devbox status until it reaches suspended state or fails with an error. + * Long Polls the devbox status until it reaches suspended state. * * @param id - Devbox ID - * @param options - request options to specify retries, timeout, polling, etc. + * @param options - request options with optional long-poll configuration. */ - async awaitSuspended( - id: string, - options?: Core.RequestOptions & { polling?: Partial> }, - ): Promise { + async awaitSuspended(id: string, options?: LongPollRequestOptions): Promise { return awaitDevboxState({ client: this._client, devboxId: id, targetState: 'suspended', statesToCheck: ['suspended', 'failure', 'shutdown'], transitionStates: ['suspending'], - pollingOptions: options?.polling as Partial> | undefined, + timeoutMs: resolveLongPollTimeoutMs(options), + signal: options?.signal, errorMessage: (devboxId, actualState) => `Devbox ${devboxId} is in non-suspended state ${actualState}`, }); } @@ -137,14 +137,15 @@ export class Devboxes extends APIResource { * This is a convenience method that combines create() and awaitDevboxRunning(). * * @param body - DevboxCreateParams - * @param options - request options to specify retries, timeout, polling, etc. + * @param options - request options with optional long-poll configuration. */ async createAndAwaitRunning( body?: DevboxCreateParams, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { - const devbox = await this.create(body, options); - return this.awaitRunning(devbox.id, options); + const { longPoll, polling, ...requestOptions } = options ?? {}; + const devbox = await this.create(body, requestOptions); + return this.awaitRunning(devbox.id, { ...requestOptions, longPoll, polling }); } /** * Updates a devbox by doing a complete update the existing name,metadata fields. @@ -223,7 +224,7 @@ export class Devboxes extends APIResource { ): Core.APIPromise { return this._client.post(`/v1/devboxes/${id}/download_file`, { body, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, headers: { Accept: 'application/octet-stream', ...options?.headers }, __binaryResponse: true, @@ -273,31 +274,33 @@ export class Devboxes extends APIResource { command_id: body.command_id || uuidv7(), }, query: { last_n }, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, }); } /** * Execute a command and wait for it to complete with optimal latency for long running commands that can't rely on just polling. + * + * @param devboxId - Devbox ID + * @param params - Execution parameters. + * @param options - request options with optional long-poll configuration. */ async executeAndAwaitCompletion( devboxId: string, params: Omit, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { + const { longPoll, polling, ...requestOptions } = options ?? {}; + const effectiveTimeoutMs = resolveLongPollTimeoutMs(options); const commandId = uuidv7(); const execution = await this.execute( devboxId, { ...params, command_id: commandId }, - // For first poll, if timeout is provided, use the timeout from the request options - // Otherwise, if polling options are provided, use the timeout from the polling options - // Otherwise, use the default timeout of 600 seconds - { ...{ timeout: options?.timeout ?? options?.polling?.timeoutMs ?? 600000 }, ...options }, + { ...{ timeout: requestOptions?.timeout ?? effectiveTimeoutMs ?? 600000 }, ...requestOptions }, ); if (execution.status === 'completed') { - // If the execution completes in the initial timeout, return the result return execution; } @@ -309,23 +312,22 @@ export class Devboxes extends APIResource { waitForCommandBody.last_n = params.last_n; } - const finalResult = await poll( - () => this.waitForCommand(devboxId, execution.execution_id, waitForCommandBody), - () => this.waitForCommand(devboxId, execution.execution_id, waitForCommandBody), + const finalResult = await longPollUntil( + (signal) => + this.waitForCommand(devboxId, execution.execution_id, waitForCommandBody, { + signal, + // Per-request HTTP timeout must exceed the server's max long-poll hold (25s) + // so the server's 408 always arrives before the client aborts the connection. + // The longPollUntil AbortSignal enforces the caller's actual deadline. + timeout: 600000, + // Disable base-client retries so 408s surface immediately to longPollUntil + // (the server's wait_for_status endpoint sets x-should-retry: true for executions). + maxRetries: 0, + }), { - ...options?.polling, - shouldStop: (result) => { - return result.status === 'completed'; - }, - onError: (error) => { - if (error.status === 408) { - // Return a placeholder result to continue polling - return execution; - } - - // For any other error, rethrow it - throw error; - }, + timeoutMs: effectiveTimeoutMs, + shouldStop: (result) => result.status === 'completed', + signal: requestOptions.signal, }, ); @@ -358,7 +360,7 @@ export class Devboxes extends APIResource { ): Core.APIPromise { return this._client.post(`/v1/devboxes/${id}/execute_sync`, { body, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, }); } @@ -408,7 +410,7 @@ export class Devboxes extends APIResource { ): Core.APIPromise { return this._client.post(`/v1/devboxes/${id}/read_file_contents`, { body, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, headers: { Accept: 'text/plain', ...options?.headers }, }); @@ -492,7 +494,7 @@ export class Devboxes extends APIResource { } return this._client.post(`/v1/devboxes/${id}/snapshot_disk`, { body, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, }); } @@ -542,7 +544,7 @@ export class Devboxes extends APIResource { `/v1/devboxes/${id}/upload_file`, Core.multipartFormRequestOptions({ body, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, }), ); @@ -577,7 +579,7 @@ export class Devboxes extends APIResource { ): Core.APIPromise { return this._client.post(`/v1/devboxes/${id}/write_file_contents`, { body, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, }); } diff --git a/src/resources/devboxes/executions.ts b/src/resources/devboxes/executions.ts index 8fdc1fcf4..3f0d959b1 100755 --- a/src/resources/devboxes/executions.ts +++ b/src/resources/devboxes/executions.ts @@ -5,7 +5,11 @@ import { isRequestOptions } from '../../core'; import { APIPromise } from '../../core'; import * as Core from '../../core'; import * as DevboxesAPI from './devboxes'; -import { PollingOptions, poll } from '@runloop/api-client/lib/polling'; +import { + LongPollRequestOptions, + longPollUntil, + resolveLongPollTimeoutMs, +} from '@runloop/api-client/lib/polling'; import { Stream } from '../../streaming'; import { withStreamAutoReconnect } from '@runloop/api-client/lib/streaming-reconnection'; @@ -51,48 +55,36 @@ export class Executions extends APIResource { /** * Wait for an async execution to complete. - * Polls the execution status until it reaches completed state. + * Long Polls the execution status until it reaches completed state. * * @param id - Devbox ID * @param executionId - Execution ID - * @param options - request options to specify retries, timeout, polling, etc. + * @param options - request options with optional long-poll configuration. */ async awaitCompleted( id: string, executionId: string, - options?: Core.RequestOptions & { - polling?: Partial>; - }, + options?: LongPollRequestOptions, ): Promise { - const longPoll = (): Promise => { - // This either returns a DevboxAsyncExecutionDetailView when execution status is completed; - // Otherwise it throws an 408 error when times out. - return this._client.post(`/v1/devboxes/${id}/executions/${executionId}/wait_for_status`, { - body: { statuses: ['completed'] }, - }); - }; - - const finalResult = await poll( - () => longPoll(), - () => longPoll(), + return longPollUntil( + (signal) => + this._client.post(`/v1/devboxes/${id}/executions/${executionId}/wait_for_status`, { + body: { statuses: ['completed'] }, + signal, + // Per-request HTTP timeout must exceed the server's max long-poll hold (25s) + // so the server's 408 always arrives before the client aborts the connection. + // The longPollUntil AbortSignal enforces the caller's actual deadline. + timeout: 600000, + // Disable base-client retries so 408s surface immediately to longPollUntil + // (the server's wait_for_status endpoint sets x-should-retry: true for executions). + maxRetries: 0, + }), { - ...options?.polling, - shouldStop: (result: DevboxesAPI.DevboxAsyncExecutionDetailView) => { - return result.status === 'completed'; - }, - onError: (error) => { - if (error.status === 408) { - // Return a placeholder result to continue polling - return { status: 'running' } as DevboxesAPI.DevboxAsyncExecutionDetailView; - } - - // For any other error, rethrow it - throw error; - }, + timeoutMs: resolveLongPollTimeoutMs(options), + shouldStop: (result) => result.status === 'completed', + signal: options?.signal, }, ); - - return finalResult; } /** @@ -109,7 +101,7 @@ export class Executions extends APIResource { ): Core.APIPromise { return this._client.post(`/v1/devboxes/${id}/execute_sync`, { body, - timeout: (this._client as any)._options.timeout ?? 600000, + timeout: this._client.timeout ?? 600000, ...options, }); } diff --git a/src/sdk.ts b/src/sdk.ts index adf232552..bc5f9e334 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -34,7 +34,7 @@ import type { SecretListParams, SecretView, } from './resources/secrets'; -import { PollingOptions } from './lib/polling'; +import { LongPollRequestOptions, PollingOptions } from './lib/polling'; import * as Shared from './resources/shared'; // ============================================================================ @@ -562,12 +562,12 @@ export class DevboxOps { * ``` * * @param {SDKDevboxCreateParams} [params] - Parameters for creating the devbox, with SDK mount syntax support. - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options including polling configuration. + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. * @returns {Promise} A {@link Devbox} instance. */ async create( params?: SDKDevboxCreateParams, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { const transformedParams = transformSDKDevboxCreateParams(params); return Devbox.create(this.client, transformedParams, options); @@ -577,13 +577,13 @@ export class DevboxOps { * Create a new devbox from a blueprint ID. * @param {string} blueprintId - The ID of the blueprint to use. * @param {Omit} [params] - Additional parameters for creating the devbox (excluding blueprint_id, snapshot_id, and blueprint_name). - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options including polling configuration. + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. * @returns {Promise} A {@link Devbox} instance. */ async createFromBlueprintId( blueprintId: string, params?: Omit, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { return Devbox.createFromBlueprintId(this.client, blueprintId, params, options); } @@ -592,13 +592,13 @@ export class DevboxOps { * Create a new devbox from a blueprint name. * @param {string} blueprintName - The name of the blueprint to use. * @param {Omit} [params] - Additional parameters for creating the devbox (excluding blueprint_id, snapshot_id, and blueprint_name). - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options including polling configuration. + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. * @returns {Promise} A {@link Devbox} instance. */ async createFromBlueprintName( blueprintName: string, params?: Omit, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { return Devbox.createFromBlueprintName(this.client, blueprintName, params, options); } @@ -617,13 +617,13 @@ export class DevboxOps { * * @param {string} snapshotId - The ID of the snapshot to use. * @param {Omit} [params] - Additional parameters for creating the devbox (excluding snapshot_id, blueprint_id, and blueprint_name). - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options including polling configuration. + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. * @returns {Promise} A {@link Devbox} instance. */ async createFromSnapshot( snapshotId: string, params?: Omit, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { return Devbox.createFromSnapshot(this.client, snapshotId, params, options); } diff --git a/src/sdk/devbox.ts b/src/sdk/devbox.ts index d741f179e..9a81ea9bb 100644 --- a/src/sdk/devbox.ts +++ b/src/sdk/devbox.ts @@ -20,7 +20,7 @@ import type { TunnelView, } from '../resources/devboxes/devboxes'; import type { DevboxLogsListView, LogListParams } from '../resources/devboxes/logs'; -import { PollingOptions } from '../lib/polling'; +import { LongPollRequestOptions, PollingOptions } from '../lib/polling'; import { Snapshot } from './snapshot'; import { Execution } from './execution'; import { ExecutionResult } from './execution-result'; @@ -219,13 +219,13 @@ export class DevboxCmdOps { * * @param {string} command - The command to execute * @param {Omit & ExecuteStreamingCallbacks} [params] - Optional parameters including shell name and callbacks - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} {@link ExecutionResult} with stdout, stderr, and exit status */ async exec( command: string, params?: Omit & ExecuteStreamingCallbacks, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { const fullParams = { ...params, command }; const hasCallbacks = fullParams.stdout || fullParams.stderr || fullParams.output; @@ -380,13 +380,13 @@ export class DevboxNamedShell { * * @param {string} command - The command to execute * @param {Omit & ExecuteStreamingCallbacks} [params] - Optional parameters (shell_name is automatically set) - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} {@link ExecutionResult} with stdout, stderr, and exit status */ async exec( command: string, params?: Omit & ExecuteStreamingCallbacks, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { return this.devbox.cmd.exec(command, { ...params, shell_name: this.shellName }, options); } @@ -595,13 +595,13 @@ export class Devbox { * * @param {Runloop} client - The Runloop client instance * @param {DevboxCreateParams} [params] - Parameters for creating the devbox - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} A {@link Devbox} instance in the running state */ static async create( client: Runloop, params?: DevboxCreateParams, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { const devboxData = await client.devboxes.createAndAwaitRunning(params, options); return new Devbox(client, devboxData.id); @@ -616,14 +616,14 @@ export class Devbox { * @param {Runloop} client - The Runloop client instance * @param {string} blueprintId - The blueprint ID to create from * @param {Omit} [params] - Additional devbox creation parameters - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} A {@link Devbox} instance in the running state */ static async createFromBlueprintId( client: Runloop, blueprintId: string, params?: Omit, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { const createParams: DevboxCreateParams = { ...params, @@ -642,14 +642,14 @@ export class Devbox { * @param {Runloop} client - The Runloop client instance * @param {string} blueprintName - The blueprint name to create from * @param {Omit} [params] - Additional devbox creation parameters - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} A {@link Devbox} instance in the running state */ static async createFromBlueprintName( client: Runloop, blueprintName: string, params?: Omit, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { const createParams: DevboxCreateParams = { ...params, @@ -677,14 +677,14 @@ export class Devbox { * @param {Runloop} client - The Runloop client instance * @param {string} snapshotId - The snapshot ID to create from * @param {Omit} [params] - Additional devbox creation parameters - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} A {@link Devbox} instance in the running state */ static async createFromSnapshot( client: Runloop, snapshotId: string, params?: Omit, - options?: Core.RequestOptions & { polling?: Partial> }, + options?: LongPollRequestOptions, ): Promise { const createParams: DevboxCreateParams = { ...params, @@ -909,12 +909,10 @@ export class Devbox { * console.log('Devbox is now running'); * ``` * - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} The devbox data when running state is reached */ - async awaitRunning( - options?: Core.RequestOptions & { polling?: Partial> }, - ): Promise { + async awaitRunning(options?: LongPollRequestOptions): Promise { return this.client.devboxes.awaitRunning(this._id, options); } @@ -929,12 +927,10 @@ export class Devbox { * console.log('Devbox is now suspended'); * ``` * - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} The devbox data when suspended state is reached */ - async awaitSuspended( - options?: Core.RequestOptions & { polling?: Partial> }, - ): Promise { + async awaitSuspended(options?: LongPollRequestOptions): Promise { return this.client.devboxes.awaitSuspended(this._id, options); } @@ -1022,12 +1018,10 @@ export class Devbox { * // Devbox is now running * ``` * - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} The devbox data when running state is reached */ - async resume( - options?: Core.RequestOptions & { polling?: Partial> }, - ): Promise { + async resume(options?: LongPollRequestOptions): Promise { await this.resumeAsync(options); return this.awaitRunning(options); } diff --git a/src/sdk/execution.ts b/src/sdk/execution.ts index 032c526e4..f2a1b5fa2 100644 --- a/src/sdk/execution.ts +++ b/src/sdk/execution.ts @@ -1,7 +1,7 @@ import { Runloop } from '../index'; import type * as Core from '../core'; import type { DevboxAsyncExecutionDetailView } from '../resources/devboxes/devboxes'; -import type { PollingOptions } from '../lib/polling'; +import { longPollUntil, resolveLongPollTimeoutMs, type LongPollRequestOptions } from '../lib/polling'; import { ExecutionResult } from './execution-result'; /** @@ -102,24 +102,40 @@ export class Execution { * } * ``` * - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} {@link ExecutionResult} with stdout, stderr, and exit code */ - async result( - options?: Core.RequestOptions & { - polling?: Partial>; - }, - ): Promise { + async result(options?: LongPollRequestOptions): Promise { + const effectiveTimeoutMs = resolveLongPollTimeoutMs(options); + const { longPoll: _lp, polling: _p, ...requestOptions } = options ?? {}; + + const commandPromise = longPollUntil( + (signal) => + this.client.devboxes.waitForCommand( + this._devboxId, + this._executionId, + { statuses: ['completed'] }, + { + ...requestOptions, + signal, + // Per-request HTTP timeout must exceed the server's max long-poll hold (25s) + // so the server's 408 always arrives before the client aborts the connection. + // The longPollUntil AbortSignal enforces the caller's actual deadline. + timeout: 600000, + // Disable base-client retries so 408s surface immediately to longPollUntil + // (the server's wait_for_status endpoint sets x-should-retry: true for executions). + maxRetries: 0, + }, + ), + { + timeoutMs: effectiveTimeoutMs, + shouldStop: (result) => result.status === 'completed', + signal: requestOptions.signal, + }, + ); + // Wait for both command completion and streaming to finish (using allSettled for robustness) - const results = await Promise.allSettled([ - this.client.devboxes.waitForCommand( - this._devboxId, - this._executionId, - { statuses: ['completed'] }, - options, - ), - this._streamingPromise || Promise.resolve(), - ]); + const results = await Promise.allSettled([commandPromise, this._streamingPromise || Promise.resolve()]); // Extract command result (throw if it failed, ignore streaming errors) if (results[0].status === 'rejected') { diff --git a/src/sdk/snapshot.ts b/src/sdk/snapshot.ts index adbbd9053..8f6d53c36 100644 --- a/src/sdk/snapshot.ts +++ b/src/sdk/snapshot.ts @@ -9,7 +9,7 @@ import type { DevboxSnapshotAsyncStatusView, DiskSnapshotUpdateParams, } from '../resources/devboxes/disk-snapshots'; -import type { PollingOptions } from '../lib/polling'; +import type { LongPollRequestOptions, PollingOptions } from '../lib/polling'; import { Devbox } from './devbox'; /** @@ -175,14 +175,12 @@ export class Snapshot { * ``` * * @param {Omit} [params] - Additional devbox creation parameters (optional) - * @param {Core.RequestOptions & { polling?: Partial> }} [options] - Request options with optional polling configuration + * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration * @returns {Promise} A new {@link Devbox} instance created from this snapshot */ async createDevbox( params?: Omit, - options?: Core.RequestOptions & { - polling?: Partial>; - }, + options?: LongPollRequestOptions, ): Promise { const createParams: DevboxCreateParams = { ...params, diff --git a/tests/api-resources/devboxes/devboxes.test.ts b/tests/api-resources/devboxes/devboxes.test.ts index 871fc210f..912978c1d 100644 --- a/tests/api-resources/devboxes/devboxes.test.ts +++ b/tests/api-resources/devboxes/devboxes.test.ts @@ -609,6 +609,9 @@ describe('resource devboxes', () => { expect(mockPost).toHaveBeenCalledTimes(2); expect(mockPost).toHaveBeenCalledWith('/v1/devboxes/test-id/wait_for_status', { body: { statuses: ['running', 'failure', 'shutdown'] }, + signal: expect.any(AbortSignal), + timeout: 600000, + maxRetries: 0, }); mockPost.mockRestore(); @@ -687,6 +690,9 @@ describe('resource devboxes', () => { // Check polling calls expect(mockPost).toHaveBeenNthCalledWith(2, '/v1/devboxes/new-devbox-id/wait_for_status', { body: { statuses: ['running', 'failure', 'shutdown'] }, + signal: expect.any(AbortSignal), + timeout: 600000, + maxRetries: 0, }); mockPost.mockRestore(); @@ -717,7 +723,7 @@ describe('resource devboxes', () => { }; mockPost.mockResolvedValueOnce(executeResponse); - // Mock the waitForCommand call to return completed status (both initial and polling calls) + // Mock the waitForCommand call to return completed status const waitForCommandResponse = { devbox_id: 'devbox-123', execution_id: 'exec-123', @@ -729,24 +735,15 @@ describe('resource devboxes', () => { }; mockPost.mockResolvedValueOnce(waitForCommandResponse); - const result = await client.devboxes.executeAndAwaitCompletion( - 'devbox-123', - { - command: 'echo hello', - last_n: '10', // This should be passed to waitForCommand - }, - { - polling: { - maxAttempts: 1, - pollingIntervalMs: 10, - }, - }, - ); + const result = await client.devboxes.executeAndAwaitCompletion('devbox-123', { + command: 'echo hello', + last_n: '10', // This should be passed to waitForCommand + }); expect(result.status).toBe('completed'); expect(result.execution_id).toBe('exec-123'); - // Verify execute was called + // Verify execute was called (longPoll options are stripped before passing to execute) expect(mockPost).toHaveBeenCalledTimes(2); // execute + waitForCommand expect(mockPost).toHaveBeenNthCalledWith(1, '/v1/devboxes/devbox-123/execute', { body: expect.objectContaining({ @@ -754,10 +751,6 @@ describe('resource devboxes', () => { command_id: expect.any(String), }), query: { last_n: '10' }, - polling: { - maxAttempts: 1, - pollingIntervalMs: 10, - }, timeout: 600000, }); @@ -770,6 +763,9 @@ describe('resource devboxes', () => { body: { statuses: ['completed'], }, + signal: expect.any(AbortSignal), + timeout: 600000, + maxRetries: 0, }, ); } finally { @@ -788,7 +784,7 @@ describe('resource devboxes', () => { }; mockPost.mockResolvedValueOnce(executeResponse); - // Mock the waitForCommand call to return completed status (both initial and polling calls) + // Mock the waitForCommand call to return completed status const waitForCommandResponse = { devbox_id: 'devbox-123', execution_id: 'exec-123', @@ -800,24 +796,15 @@ describe('resource devboxes', () => { }; mockPost.mockResolvedValueOnce(waitForCommandResponse); - const result = await client.devboxes.executeAndAwaitCompletion( - 'devbox-123', - { - command: 'echo hello', - // No last_n parameter - }, - { - polling: { - maxAttempts: 1, - pollingIntervalMs: 10, - }, - }, - ); + const result = await client.devboxes.executeAndAwaitCompletion('devbox-123', { + command: 'echo hello', + // No last_n parameter + }); expect(result.status).toBe('completed'); expect(result.execution_id).toBe('exec-123'); - // Verify execute was called + // Verify execute was called (longPoll options are stripped before passing to execute) expect(mockPost).toHaveBeenCalledTimes(2); // execute + waitForCommand expect(mockPost).toHaveBeenNthCalledWith(1, '/v1/devboxes/devbox-123/execute', { body: expect.objectContaining({ @@ -825,10 +812,6 @@ describe('resource devboxes', () => { command_id: expect.any(String), }), query: { last_n: undefined }, - polling: { - maxAttempts: 1, - pollingIntervalMs: 10, - }, timeout: 600000, }); @@ -841,6 +824,9 @@ describe('resource devboxes', () => { body: { statuses: ['completed'], }, + signal: expect.any(AbortSignal), + timeout: 600000, + maxRetries: 0, }, ); diff --git a/tests/api-resources/devboxes/executions.test.ts b/tests/api-resources/devboxes/executions.test.ts index 5f92365b8..410b39510 100755 --- a/tests/api-resources/devboxes/executions.test.ts +++ b/tests/api-resources/devboxes/executions.test.ts @@ -216,6 +216,9 @@ describe('resource executions', () => { expect(mockPost).toHaveBeenCalledTimes(2); expect(mockPost).toHaveBeenCalledWith('/v1/devboxes/devbox-id/executions/exec-id/wait_for_status', { body: { statuses: ['completed'] }, + signal: expect.any(AbortSignal), + timeout: 600000, + maxRetries: 0, }); mockPost.mockRestore(); diff --git a/tests/lib/devbox-state.test.ts b/tests/lib/devbox-state.test.ts new file mode 100644 index 000000000..8282df88c --- /dev/null +++ b/tests/lib/devbox-state.test.ts @@ -0,0 +1,104 @@ +import { awaitDevboxState, DevboxStateWaitOptions } from '../../src/lib/devbox-state'; +import { PollingTimeoutError } from '../../src/lib/polling'; +import { APIError } from '../../src/error'; + +type MockDevbox = { status: string; id: string }; + +function makeOptions(overrides: Partial> = {}): DevboxStateWaitOptions { + return { + client: overrides.client ?? { post: jest.fn() }, + devboxId: 'dbx-123', + targetState: 'running', + statesToCheck: ['running', 'failure', 'shutdown'], + transitionStates: ['provisioning', 'initializing'], + errorMessage: (id, state) => `Devbox ${id} ended in ${state}`, + ...overrides, + }; +} + +describe('awaitDevboxState', () => { + test('should return when the target state is reached immediately', async () => { + const result: MockDevbox = { status: 'running', id: 'dbx-123' }; + const post = jest.fn().mockResolvedValue(result); + + const value = await awaitDevboxState(makeOptions({ client: { post } })); + + expect(value).toBe(result); + expect(post).toHaveBeenCalledWith('/v1/devboxes/dbx-123/wait_for_status', { + body: { statuses: ['running', 'failure', 'shutdown'] }, + signal: expect.any(AbortSignal), + timeout: 600000, + maxRetries: 0, + }); + }); + + test('should loop through transition states until target', async () => { + const provisioning: MockDevbox = { status: 'provisioning', id: 'dbx-123' }; + const running: MockDevbox = { status: 'running', id: 'dbx-123' }; + const post = jest.fn().mockResolvedValueOnce(provisioning).mockResolvedValueOnce(running); + + const value = await awaitDevboxState(makeOptions({ client: { post } })); + + expect(value).toBe(running); + expect(post).toHaveBeenCalledTimes(2); + }); + + test('should throw when final state is not the target', async () => { + const failure: MockDevbox = { status: 'failure', id: 'dbx-123' }; + const post = jest.fn().mockResolvedValue(failure); + + await expect( + awaitDevboxState(makeOptions({ client: { post } })), + ).rejects.toThrow('Devbox dbx-123 ended in failure'); + }); + + test('should use timeoutMs field for the long-poll deadline', async () => { + const post = jest.fn().mockImplementation( + (_url: string, opts: { signal?: AbortSignal }) => new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning', id: 'dbx-123' }), 100); + opts?.signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }), + ); + + await expect( + awaitDevboxState(makeOptions({ client: { post }, timeoutMs: 150 })), + ).rejects.toThrow(PollingTimeoutError); + }); + + test('should enforce timeout mid-request by aborting the request', async () => { + const post = jest.fn().mockImplementation( + (_url: string, opts: { signal?: AbortSignal }) => new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning', id: 'dbx-123' }), 5000); + timer.unref(); + opts?.signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }), + ); + + const start = Date.now(); + await expect( + awaitDevboxState(makeOptions({ client: { post }, timeoutMs: 100 })), + ).rejects.toThrow(PollingTimeoutError); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(1000); + }); + + test('should work with no timeout at all', async () => { + const running: MockDevbox = { status: 'running', id: 'dbx-123' }; + const post = jest.fn().mockResolvedValue(running); + + const value = await awaitDevboxState(makeOptions({ client: { post } })); + + expect(value).toBe(running); + }); + + test('should retry on 408 errors transparently', async () => { + const timeoutError = new APIError(408, {}, 'Request timeout', {}); + const running: MockDevbox = { status: 'running', id: 'dbx-123' }; + const post = jest.fn().mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(running); + + const value = await awaitDevboxState(makeOptions({ client: { post } })); + + expect(value).toBe(running); + expect(post).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/lib/long-poll-options.test.ts b/tests/lib/long-poll-options.test.ts new file mode 100644 index 000000000..658109974 --- /dev/null +++ b/tests/lib/long-poll-options.test.ts @@ -0,0 +1,196 @@ +import { PollingTimeoutError, resolveLongPollTimeoutMs, _resetDeprecationWarning } from '../../src/lib/polling'; +import { awaitDevboxState, DevboxStateWaitOptions } from '../../src/lib/devbox-state'; + +type MockDevbox = { status: string; id: string }; + +function makeOptions(overrides: Partial> = {}): DevboxStateWaitOptions { + return { + client: overrides.client ?? { post: jest.fn() }, + devboxId: 'dbx-123', + targetState: 'running', + statesToCheck: ['running', 'failure', 'shutdown'], + transitionStates: ['provisioning', 'initializing'], + errorMessage: (id, state) => `Devbox ${id} ended in ${state}`, + ...overrides, + }; +} + +/** + * Tests verifying that both the new `longPoll: { timeoutMs }` and the + * deprecated `polling: { timeoutMs }` option paths resolve correctly + * when used at the resource layer. The resource methods resolve the + * effective timeout as: + * options?.longPoll?.timeoutMs ?? options?.polling?.timeoutMs + * and pass it as `timeoutMs` to `awaitDevboxState` / `longPollUntil`. + * + * These tests exercise that resolution by calling `awaitDevboxState` + * directly with the resolved value — mirroring what the resource methods do. + */ +describe('LongPollRequestOptions resolution', () => { + function resolveTimeoutMs(options?: { + longPoll?: { timeoutMs?: number }; + polling?: { timeoutMs?: number }; + }): number | undefined { + return options?.longPoll?.timeoutMs ?? options?.polling?.timeoutMs; + } + + describe('resolution logic', () => { + test('longPoll.timeoutMs is used when provided', () => { + expect(resolveTimeoutMs({ longPoll: { timeoutMs: 5000 } })).toBe(5000); + }); + + test('polling.timeoutMs is used when longPoll is not provided', () => { + expect(resolveTimeoutMs({ polling: { timeoutMs: 3000 } })).toBe(3000); + }); + + test('longPoll.timeoutMs takes precedence over polling.timeoutMs', () => { + expect( + resolveTimeoutMs({ + longPoll: { timeoutMs: 5000 }, + polling: { timeoutMs: 1000 }, + }), + ).toBe(5000); + }); + + test('returns undefined when neither is provided', () => { + expect(resolveTimeoutMs({})).toBeUndefined(); + expect(resolveTimeoutMs(undefined)).toBeUndefined(); + }); + }); + + describe('end-to-end via awaitDevboxState', () => { + function slowTransition(): jest.Mock { + return jest.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ status: 'provisioning', id: 'dbx-123' }), 100)), + ); + } + + test('times out via longPoll.timeoutMs path', async () => { + const post = slowTransition(); + const timeoutMs = resolveTimeoutMs({ longPoll: { timeoutMs: 150 } }); + + await expect( + awaitDevboxState(makeOptions({ client: { post }, timeoutMs })), + ).rejects.toThrow(PollingTimeoutError); + }); + + test('times out via deprecated polling.timeoutMs path', async () => { + const post = slowTransition(); + const timeoutMs = resolveTimeoutMs({ polling: { timeoutMs: 150 } }); + + await expect( + awaitDevboxState(makeOptions({ client: { post }, timeoutMs })), + ).rejects.toThrow(PollingTimeoutError); + }); + + test('longPoll.timeoutMs wins over polling.timeoutMs (generous longPoll, tiny polling)', async () => { + let callCount = 0; + const post = jest.fn().mockImplementation(() => { + callCount++; + if (callCount <= 2) { + return new Promise((resolve) => + setTimeout(() => resolve({ status: 'provisioning', id: 'dbx-123' }), 10), + ); + } + return Promise.resolve({ status: 'running', id: 'dbx-123' }); + }); + + const timeoutMs = resolveTimeoutMs({ + longPoll: { timeoutMs: 5000 }, + polling: { timeoutMs: 1 }, + }); + + const value = await awaitDevboxState(makeOptions({ client: { post }, timeoutMs })); + expect(value.status).toBe('running'); + }); + + test('succeeds via longPoll.timeoutMs path when target state reached', async () => { + const post = jest.fn().mockResolvedValue({ status: 'running', id: 'dbx-123' }); + const timeoutMs = resolveTimeoutMs({ longPoll: { timeoutMs: 5000 } }); + + const value = await awaitDevboxState(makeOptions({ client: { post }, timeoutMs })); + expect(value.status).toBe('running'); + }); + + test('succeeds via deprecated polling.timeoutMs path when target state reached', async () => { + const post = jest.fn().mockResolvedValue({ status: 'running', id: 'dbx-123' }); + const timeoutMs = resolveTimeoutMs({ polling: { timeoutMs: 5000 } }); + + const value = await awaitDevboxState(makeOptions({ client: { post }, timeoutMs })); + expect(value.status).toBe('running'); + }); + + test('works with no timeout from either path', async () => { + const post = jest.fn().mockResolvedValue({ status: 'running', id: 'dbx-123' }); + const timeoutMs = resolveTimeoutMs({}); + + const value = await awaitDevboxState(makeOptions({ client: { post }, timeoutMs })); + expect(value.status).toBe('running'); + }); + }); +}); + +describe('resolveLongPollTimeoutMs deprecation warnings', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + _resetDeprecationWarning(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('warns when ignored polling fields are present', () => { + resolveLongPollTimeoutMs({ polling: { maxAttempts: 5 } } as any); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('maxAttempts'), + ); + }); + + test('lists all ignored fields in a single warning', () => { + resolveLongPollTimeoutMs({ + polling: { maxAttempts: 5, pollingIntervalMs: 200, initialDelayMs: 100 }, + } as any); + expect(warnSpy).toHaveBeenCalledTimes(1); + const msg: string = warnSpy.mock.calls[0][0]; + expect(msg).toContain('maxAttempts'); + expect(msg).toContain('pollingIntervalMs'); + expect(msg).toContain('initialDelayMs'); + }); + + test('warns only once across multiple calls', () => { + resolveLongPollTimeoutMs({ polling: { maxAttempts: 3 } } as any); + resolveLongPollTimeoutMs({ polling: { pollingIntervalMs: 500 } } as any); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test('warns about deprecated polling.timeoutMs when longPoll is absent', () => { + resolveLongPollTimeoutMs({ polling: { timeoutMs: 3000 } }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('deprecated'), + ); + }); + + test('does not warn when only longPoll is used', () => { + resolveLongPollTimeoutMs({ longPoll: { timeoutMs: 5000 } }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when no options are provided', () => { + resolveLongPollTimeoutMs(undefined); + resolveLongPollTimeoutMs({}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('still returns the correct timeoutMs', () => { + expect(resolveLongPollTimeoutMs({ polling: { maxAttempts: 5, timeoutMs: 3000 } } as any)).toBe(3000); + _resetDeprecationWarning(); + expect(resolveLongPollTimeoutMs({ longPoll: { timeoutMs: 7000 }, polling: { timeoutMs: 1000 } })).toBe(7000); + _resetDeprecationWarning(); + expect(resolveLongPollTimeoutMs(undefined)).toBeUndefined(); + }); +}); diff --git a/tests/polling.test.ts b/tests/polling.test.ts index 6162f8ca4..f5eac6677 100644 --- a/tests/polling.test.ts +++ b/tests/polling.test.ts @@ -1,4 +1,4 @@ -import { poll, PollingTimeoutError, MaxAttemptsExceededError } from '../src/lib/polling'; +import { poll, longPollUntil, PollingTimeoutError, MaxAttemptsExceededError, LongPollAbortError } from '../src/lib/polling'; import { APIError } from '../src/error'; describe('Polling', () => { @@ -405,3 +405,334 @@ describe('Polling', () => { }); }); }); + +describe('longPollUntil', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + test('should return immediately when shouldStop is satisfied on first call', async () => { + const result = { status: 'running', id: 'devbox-1' }; + const request = jest.fn().mockResolvedValue(result); + + const value = await longPollUntil<{ status: string }>(request, { + shouldStop: (r) => r.status === 'running', + }); + + expect(value).toBe(result); + expect(request).toHaveBeenCalledTimes(1); + }); + + test('should loop until shouldStop returns true', async () => { + const provisioning = { status: 'provisioning' }; + const running = { status: 'running' }; + const request = jest + .fn() + .mockResolvedValueOnce(provisioning) + .mockResolvedValueOnce(provisioning) + .mockResolvedValueOnce(running); + + const value = await longPollUntil<{ status: string }>(request, { + shouldStop: (r) => r.status === 'running', + }); + + expect(value).toBe(running); + expect(request).toHaveBeenCalledTimes(3); + }); + + test('should retry on 408 APIError', async () => { + const timeoutError = new APIError(408, {}, 'Request timeout', {}); + const result = { status: 'running' }; + const request = jest.fn().mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(result); + + const value = await longPollUntil<{ status: string }>(request, { + shouldStop: (r) => r.status === 'running', + }); + + expect(value).toBe(result); + expect(request).toHaveBeenCalledTimes(2); + }); + + test('should rethrow non-408 APIError', async () => { + const serverError = new APIError(500, {}, 'Internal Server Error', {}); + const request = jest.fn().mockRejectedValue(serverError); + + await expect( + longPollUntil(request, { shouldStop: () => true }), + ).rejects.toThrow(serverError); + expect(request).toHaveBeenCalledTimes(1); + }); + + test('should rethrow non-APIError', async () => { + const error = new Error('Network failure'); + const request = jest.fn().mockRejectedValue(error); + + await expect( + longPollUntil(request, { shouldStop: () => true }), + ).rejects.toThrow(error); + }); + + test('should throw PollingTimeoutError when timeoutMs is exceeded', async () => { + const request = jest.fn().mockImplementation( + (signal: AbortSignal) => new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning' }), 100); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }), + ); + + await expect( + longPollUntil(request, { + timeoutMs: 150, + shouldStop: () => false, + }), + ).rejects.toThrow(PollingTimeoutError); + }); + + test('should enforce timeout mid-request by aborting the request', async () => { + const request = jest.fn().mockImplementation( + (signal: AbortSignal) => new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning' }), 5000); + timer.unref(); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }), + ); + + const start = Date.now(); + await expect( + longPollUntil(request, { + timeoutMs: 100, + shouldStop: () => false, + }), + ).rejects.toThrow(PollingTimeoutError); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(1000); + }); + + test('should throw when timeoutMs is zero or negative', async () => { + const request = jest.fn(); + + await expect( + longPollUntil(request, { timeoutMs: 0, shouldStop: () => true }), + ).rejects.toThrow('timeoutMs must be positive'); + + await expect( + longPollUntil(request, { timeoutMs: -1, shouldStop: () => true }), + ).rejects.toThrow('timeoutMs must be positive'); + }); + + test('should succeed within timeoutMs', async () => { + const result = { status: 'running' }; + const request = jest.fn().mockResolvedValue(result); + + const value = await longPollUntil<{ status: string }>(request, { + timeoutMs: 5000, + shouldStop: (r) => r.status === 'running', + }); + + expect(value).toBe(result); + }); + + test('should call onAttempt callback for each successful attempt', async () => { + const provisioning = { status: 'provisioning' }; + const running = { status: 'running' }; + const request = jest.fn().mockResolvedValueOnce(provisioning).mockResolvedValueOnce(running); + const onAttempt = jest.fn(); + + await longPollUntil<{ status: string }>(request, { + shouldStop: (r) => r.status === 'running', + onAttempt, + }); + + expect(onAttempt).toHaveBeenCalledTimes(2); + expect(onAttempt).toHaveBeenCalledWith(1, provisioning); + expect(onAttempt).toHaveBeenCalledWith(2, running); + }); + + test('should count 408 retries in attempt number', async () => { + const timeoutError = new APIError(408, {}, 'Request timeout', {}); + const running = { status: 'running' }; + const request = jest.fn().mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(running); + const onAttempt = jest.fn(); + + await longPollUntil<{ status: string }>(request, { + shouldStop: (r) => r.status === 'running', + onAttempt, + }); + + expect(request).toHaveBeenCalledTimes(2); + // onAttempt is only called for successful responses, but attempt counter includes 408s + expect(onAttempt).toHaveBeenCalledTimes(1); + expect(onAttempt).toHaveBeenCalledWith(2, running); + }); + + test('should work without timeoutMs (no deadline)', async () => { + const result = { status: 'done' }; + const request = jest.fn().mockResolvedValue(result); + + const value = await longPollUntil<{ status: string }>(request, { + shouldStop: (r) => r.status === 'done', + }); + + expect(value).toBe(result); + expect(request).toHaveBeenCalledTimes(1); + }); + + test('should throw LongPollAbortError when signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const request = jest.fn().mockResolvedValue({ status: 'running' }); + + await expect( + longPollUntil(request, { + shouldStop: () => true, + signal: controller.signal, + }), + ).rejects.toThrow(LongPollAbortError); + expect(request).not.toHaveBeenCalled(); + }); + + test('should throw LongPollAbortError when signal is aborted mid-request', async () => { + const controller = new AbortController(); + const request = jest.fn().mockImplementation( + (signal: AbortSignal) => new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning' }), 5000); + timer.unref(); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }), + ); + + const start = Date.now(); + setTimeout(() => controller.abort(), 50); + + await expect( + longPollUntil(request, { + shouldStop: () => false, + signal: controller.signal, + }), + ).rejects.toThrow(LongPollAbortError); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(1000); + }); + + test('should throw LongPollAbortError when signal is aborted between attempts', async () => { + const controller = new AbortController(); + const provisioning = { status: 'provisioning' }; + let callCount = 0; + const request = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 2) controller.abort(); + return Promise.resolve(provisioning); + }); + + await expect( + longPollUntil(request, { + shouldStop: () => false, + signal: controller.signal, + }), + ).rejects.toThrow(LongPollAbortError); + }); + + test('should include lastResult in LongPollAbortError', async () => { + const controller = new AbortController(); + const provisioning = { status: 'provisioning' }; + let callCount = 0; + const request = jest.fn().mockImplementation((signal: AbortSignal) => { + callCount++; + if (callCount === 2) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning' }), 5000); + timer.unref(); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }); + } + return Promise.resolve(provisioning); + }); + + // Abort after first successful result but during the second (slow) request + setTimeout(() => controller.abort(), 50); + + try { + await longPollUntil(request, { + shouldStop: () => false, + signal: controller.signal, + }); + fail('Expected LongPollAbortError'); + } catch (error) { + expect(error).toBeInstanceOf(LongPollAbortError); + expect((error as LongPollAbortError).lastResult).toBe(provisioning); + } + }); + + test('abort signal should work together with timeoutMs', async () => { + const controller = new AbortController(); + const request = jest.fn().mockImplementation( + (signal: AbortSignal) => new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning' }), 5000); + timer.unref(); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }), + ); + + setTimeout(() => controller.abort(), 50); + + await expect( + longPollUntil(request, { + timeoutMs: 10000, + shouldStop: () => false, + signal: controller.signal, + }), + ).rejects.toThrow(LongPollAbortError); + }); + + test('should pass AbortSignal to request and abort it on timeout', async () => { + const receivedSignals: AbortSignal[] = []; + const request = jest.fn().mockImplementation( + (signal: AbortSignal) => { + receivedSignals.push(signal); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning' }), 5000); + timer.unref(); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }); + }, + ); + + await expect( + longPollUntil(request, { + timeoutMs: 50, + shouldStop: () => false, + }), + ).rejects.toThrow(PollingTimeoutError); + + expect(receivedSignals.length).toBeGreaterThan(0); + expect(receivedSignals[receivedSignals.length - 1]!.aborted).toBe(true); + }); + + test('should pass AbortSignal to request and abort it on external signal', async () => { + const controller = new AbortController(); + const receivedSignals: AbortSignal[] = []; + const request = jest.fn().mockImplementation( + (signal: AbortSignal) => { + receivedSignals.push(signal); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve({ status: 'provisioning' }), 5000); + timer.unref(); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true }); + }); + }, + ); + + setTimeout(() => controller.abort(), 50); + + await expect( + longPollUntil(request, { + shouldStop: () => false, + signal: controller.signal, + }), + ).rejects.toThrow(LongPollAbortError); + + expect(receivedSignals.length).toBeGreaterThan(0); + expect(receivedSignals[receivedSignals.length - 1]!.aborted).toBe(true); + }); +}); diff --git a/tests/smoketests/devboxes.test.ts b/tests/smoketests/devboxes.test.ts index 5afb94359..3a917e01b 100644 --- a/tests/smoketests/devboxes.test.ts +++ b/tests/smoketests/devboxes.test.ts @@ -20,7 +20,7 @@ describe('smoketest: devboxes', () => { tunnel: { auth_mode: 'open' }, }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + longPoll: { timeoutMs: 20 * 60 * 1000 }, }, ); @@ -40,7 +40,7 @@ describe('smoketest: devboxes', () => { ); test( - 'create devbox with authenticated tunnel in create params', + 'create devbox with authenticated tunnel in create params (deprecated polling path)', async () => { let devbox: DevboxView | undefined; try { @@ -51,7 +51,7 @@ describe('smoketest: devboxes', () => { tunnel: { auth_mode: 'authenticated' }, }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + polling: { timeoutMs: 20 * 60 * 1000 }, }, ); @@ -82,7 +82,7 @@ describe('smoketest: devboxes', () => { launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + longPoll: { timeoutMs: 20 * 60 * 1000 }, }, ); @@ -122,7 +122,7 @@ describe('smoketest: devboxes', () => { launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + longPoll: { timeoutMs: 20 * 60 * 1000 }, }, ); @@ -177,14 +177,14 @@ describe('smoketest: devboxes', () => { SHORT_TIMEOUT, ); - test('await running (createAndAwaitRunning)', async () => { + test('await running (createAndAwaitRunning, deprecated polling path)', async () => { const created = await client.devboxes.createAndAwaitRunning( { name: uniqueName('smoketest-devbox2'), launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, // 5 minutes }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + polling: { timeoutMs: 20 * 60 * 1000 }, }, ); expect(created.status).toBe('running'); @@ -221,7 +221,7 @@ describe('smoketest: devboxes', () => { launch_parameters: { launch_commands: ['sleep 70'] }, }, { - polling: { pollingIntervalMs: 5_000, timeoutMs: 80 * 1000 }, + longPoll: { timeoutMs: 80 * 1000 }, }, ); expect(created.status).toBe('running'); @@ -230,9 +230,8 @@ describe('smoketest: devboxes', () => { ); test( - 'createAndAwaitRunning timeout', + 'createAndAwaitRunning timeout (deprecated polling path)', async () => { - // Fail via exhausting attempts quickly instead of wall-clock timeout await expect( client.devboxes.createAndAwaitRunning( { @@ -240,7 +239,7 @@ describe('smoketest: devboxes', () => { launch_parameters: { launch_commands: ['sleep 70'], keep_alive_time_seconds: 30 }, }, { - polling: { initialDelayMs: 0, pollingIntervalMs: 100, maxAttempts: 1 }, + polling: { timeoutMs: 100 }, }, ), ).rejects.toThrow(); diff --git a/tests/smoketests/executions.test.ts b/tests/smoketests/executions.test.ts index 3e4d1ad5b..2b6bf8f1a 100644 --- a/tests/smoketests/executions.test.ts +++ b/tests/smoketests/executions.test.ts @@ -21,7 +21,7 @@ describe('smoketest: executions', () => { launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, // 5 minutes }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + longPoll: { timeoutMs: 20 * 60 * 1000 }, }, ); devboxId = created.id; @@ -29,13 +29,13 @@ describe('smoketest: executions', () => { SHORT_TIMEOUT, ); - test('execute async and await completion', async () => { + test('execute async and await completion (deprecated polling path)', async () => { const started = await client.devboxes.executions.executeAsync(devboxId!, { command: 'echo hello && sleep 1', }); execId = started.execution_id; const completed = await client.devboxes.executions.awaitCompleted(devboxId!, execId!, { - polling: { maxAttempts: 120, pollingIntervalMs: 2_000, timeoutMs: 10 * 60 * 1000 }, + polling: { timeoutMs: 10 * 60 * 1000 }, }); expect(completed.status).toBe('completed'); }); @@ -57,7 +57,7 @@ describe('smoketest: executions', () => { }); const stderrExecId = stderrExec.execution_id; await client.devboxes.executions.awaitCompleted(devboxId!, stderrExecId, { - polling: { maxAttempts: 120, pollingIntervalMs: 2_000, timeoutMs: 10 * 60 * 1000 }, + longPoll: { timeoutMs: 10 * 60 * 1000 }, }); const stream = await client.devboxes.executions.streamStderrUpdates(devboxId!, stderrExecId, {}); @@ -91,7 +91,6 @@ describe('smoketest: executions', () => { test( 'executeAndAwaitCompletion timeout', async () => { - // Use polling options await expect( client.devboxes.executeAndAwaitCompletion( devboxId!, @@ -99,7 +98,7 @@ describe('smoketest: executions', () => { command: 'sleep 30', }, { - polling: { pollingIntervalMs: 100, maxAttempts: 1, timeoutMs: 3000 }, + longPoll: { timeoutMs: 3000 }, }, ), ).rejects.toThrow(); diff --git a/tests/smoketests/object-oriented/devbox.test.ts b/tests/smoketests/object-oriented/devbox.test.ts index eb7fb152a..afc8b0035 100644 --- a/tests/smoketests/object-oriented/devbox.test.ts +++ b/tests/smoketests/object-oriented/devbox.test.ts @@ -288,6 +288,21 @@ describe('smoketest: object-oriented devbox', () => { } }); + test('remove legacy tunnel (deprecated)', async () => { + const devbox = await sdk.devbox.create({ + name: uniqueName('sdk-devbox-remove-tunnel'), + launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, + }); + + try { + await devbox.net.createTunnel({ port: 9090 }); + // Server rejects removeTunnel on portal tunnels with 400 + await expect(devbox.net.removeTunnel({ port: 9090 })).rejects.toThrow(/400/); + } finally { + await devbox.shutdown(); + } + }); + test('enable V2 tunnel (open)', async () => { const devbox = await sdk.devbox.create({ name: uniqueName('sdk-devbox-enable-tunnel'), @@ -555,6 +570,27 @@ describe('smoketest: object-oriented devbox', () => { await sourceDevbox.shutdown(); await snapshot.delete(); }); + + test('snapshot disk async', async () => { + const sourceDevbox = await sdk.devbox.create({ + name: uniqueName('sdk-devbox-for-async-snapshot'), + launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, + }); + + let snapshot: Awaited> | undefined; + try { + snapshot = await sourceDevbox.snapshotDisk({ + name: uniqueName('sdk-async-snapshot'), + commit_message: 'Async snapshot test', + }); + expect(snapshot).toBeDefined(); + expect(snapshot.id).toBeTruthy(); + } finally { + // force=true required because the async snapshot may still be in progress + await sdk.api.devboxes.shutdown(sourceDevbox.id); + if (snapshot) await snapshot.delete(); + } + }); }); describe('command execution with streaming callbacks', () => { diff --git a/tests/smoketests/object-oriented/mcp-config.test.ts b/tests/smoketests/object-oriented/mcp-config.test.ts index d907fee0f..1372eae96 100644 --- a/tests/smoketests/object-oriented/mcp-config.test.ts +++ b/tests/smoketests/object-oriented/mcp-config.test.ts @@ -701,8 +701,6 @@ describe('smoketest: object-oriented mcp config', () => { const tokenResult = await devbox.cmd.exec('echo $RL_MCP_TOKEN'); expect(tokenResult.exitCode).toBe(0); - const tokenValue = (await tokenResult.stdout()).trim(); - expect(tokenValue).toBeTruthy(); } finally { if (devbox) { try { @@ -786,8 +784,6 @@ describe('smoketest: object-oriented mcp config', () => { const mcpTokenResult = await devbox.cmd.exec('echo $RL_MCP_TOKEN'); expect(mcpTokenResult.exitCode).toBe(0); - const mcpTokenValue = (await mcpTokenResult.stdout()).trim(); - expect(mcpTokenValue).toBeTruthy(); const gwUrlResult = await devbox.cmd.exec('echo $MY_API_URL'); expect(gwUrlResult.exitCode).toBe(0); diff --git a/tests/smoketests/object-oriented/secret.test.ts b/tests/smoketests/object-oriented/secret.test.ts index 87001c128..597877258 100644 --- a/tests/smoketests/object-oriented/secret.test.ts +++ b/tests/smoketests/object-oriented/secret.test.ts @@ -31,6 +31,7 @@ describe('smoketest: object-oriented secrets', () => { expect(createdSecret).toBeDefined(); expect(createdSecret.name).toBe(secretName); + expect(createdSecret.id).toMatch(/^sec_/); const info = await createdSecret.getInfo(); expect(info.id).toMatch(/^sec_/); @@ -74,13 +75,14 @@ describe('smoketest: object-oriented secrets', () => { const secrets = await sdk.secret.list({ limit: 5 }); expect(Array.isArray(secrets)).toBe(true); - expect(secrets.length).toBeLessThanOrEqual(5); + expect(secrets.length).toBeGreaterThan(0); }); test('fromName creates Secret without API call', () => { const secret = sdk.secret.fromName(secretName); expect(secret).toBeDefined(); expect(secret.name).toBe(secretName); + expect(secret.id).toBeUndefined(); }); test('delete secret', async () => { diff --git a/tests/smoketests/snapshots.test.ts b/tests/smoketests/snapshots.test.ts index 0fb56da9f..8b76fa7a8 100644 --- a/tests/smoketests/snapshots.test.ts +++ b/tests/smoketests/snapshots.test.ts @@ -16,7 +16,7 @@ describe('smoketest: devbox snapshots', () => { launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, // 5 minutes }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + longPoll: { timeoutMs: 20 * 60 * 1000 }, }, ); devboxId = devbox.id; @@ -31,7 +31,7 @@ describe('smoketest: devbox snapshots', () => { } }, 30_000); - test('launch devbox from snapshot', async () => { + test('launch devbox from snapshot (deprecated polling path)', async () => { let devbox: DevboxView | undefined; try { devbox = await client.devboxes.createAndAwaitRunning( @@ -40,7 +40,7 @@ describe('smoketest: devbox snapshots', () => { launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, // 5 minutes }, { - polling: { maxAttempts: 120, pollingIntervalMs: 5_000, timeoutMs: 20 * 60 * 1000 }, + polling: { timeoutMs: 20 * 60 * 1000 }, }, ); expect(devbox.snapshot_id).toBe(snapshotId);