diff --git a/src/sdk/execution.ts b/src/sdk/execution.ts index f2a1b5fa2..a72304192 100644 --- a/src/sdk/execution.ts +++ b/src/sdk/execution.ts @@ -1,6 +1,8 @@ import { Runloop } from '../index'; import type * as Core from '../core'; -import type { DevboxAsyncExecutionDetailView } from '../resources/devboxes/devboxes'; +import { RunloopError } from '../error'; +import type { DevboxAsyncExecutionDetailView, DevboxSendStdInResult } from '../resources/devboxes/devboxes'; +import type { ExecutionSendStdInParams } from '../resources/devboxes/executions'; import { longPollUntil, resolveLongPollTimeoutMs, type LongPollRequestOptions } from '../lib/polling'; import { ExecutionResult } from './execution-result'; @@ -64,21 +66,51 @@ export class Execution { } } - // Doesn't work as expected, the execution is killed when the stdin is sent. - // /** - // * Send input to the execution's stdin. - // * - // * @param input - The input to send - // * @param options - Request options - // */ - // async sendStdIn(input: string, options?: Core.RequestOptions): Promise { - // await this.client.devboxes.executions.sendStdIn( - // this._devboxId, - // this._executionId, - // { text: input }, - // options, - // ); - // } + /** + * Send input to the execution's stdin. The execution must have been started with `attach_stdin: true`. + * + * @example + * ```typescript + * const execution = await devbox.cmd.execAsync('cat', { attach_stdin: true }); + * await execution.sendStdIn('Hello from stdin!\n'); + * await execution.closeStdIn(); + * const result = await execution.result(); + * ``` + * + * @param {string} input - The text to write to stdin + * @param {Core.RequestOptions} [options] - Request options + * @returns {Promise} Promise that resolves once the input has been delivered + * @throws {RunloopError} If the API reports that the input was not delivered + */ + async sendStdIn(input: string, options?: Core.RequestOptions): Promise { + await this.sendStdInRequest({ text: input }, options); + } + + /** + * Close the execution's stdin by sending EOF, so commands that read until end of input can finish. + * + * @param {Core.RequestOptions} [options] - Request options + * @returns {Promise} Promise that resolves once EOF has been delivered + * @throws {RunloopError} If the API reports that EOF was not delivered + */ + async closeStdIn(options?: Core.RequestOptions): Promise { + await this.sendStdInRequest({ signal: 'EOF' }, options); + } + + private async sendStdInRequest( + body: ExecutionSendStdInParams, + options?: Core.RequestOptions, + ): Promise { + // executions.sendStdIn() would treat `{ signal: 'EOF' }` as RequestOptions (its `signal` key collides + // with the AbortSignal option) and drop the body, so post to the endpoint directly. + const response = await this.client.post( + `/v1/devboxes/${this._devboxId}/executions/${this._executionId}/send_std_in`, + { ...options, body }, + ); + if (!response.success) { + throw new RunloopError(`Failed to send stdin to execution ${this._executionId}`); + } + } /** * Wait for the execution to complete and return the result. diff --git a/tests/objects/execution.test.ts b/tests/objects/execution.test.ts new file mode 100644 index 000000000..f5b9e16d5 --- /dev/null +++ b/tests/objects/execution.test.ts @@ -0,0 +1,77 @@ +import { Execution } from '../../src/sdk/execution'; +import { RunloopError } from '../../src/error'; +import type { DevboxAsyncExecutionDetailView } from '../../src/resources/devboxes/devboxes'; + +jest.mock('../../src/index'); + +describe('Execution', () => { + let mockClient: any; + let execution: Execution; + + beforeEach(() => { + mockClient = { + post: jest.fn(), + } as any; + + const initialResult: DevboxAsyncExecutionDetailView = { + devbox_id: 'devbox-123', + execution_id: 'exec-456', + status: 'running', + }; + execution = new Execution(mockClient, 'devbox-123', 'exec-456', initialResult); + }); + + describe('sendStdIn', () => { + it('sends text to the execution stdin', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: true, + }); + + await execution.sendStdIn('hello\n', { timeout: 1000 }); + + expect(mockClient.post).toHaveBeenCalledWith( + '/v1/devboxes/devbox-123/executions/exec-456/send_std_in', + { timeout: 1000, body: { text: 'hello\n' } }, + ); + }); + + it('rejects when the API reports the input was not sent', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: false, + }); + + await expect(execution.sendStdIn('hello\n')).rejects.toThrow(RunloopError); + }); + }); + + describe('closeStdIn', () => { + it('sends an EOF signal to the execution stdin', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: true, + }); + + await execution.closeStdIn(); + + expect(mockClient.post).toHaveBeenCalledWith( + '/v1/devboxes/devbox-123/executions/exec-456/send_std_in', + { body: { signal: 'EOF' } }, + ); + }); + + it('rejects when the API reports EOF was not sent', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: false, + }); + + await expect(execution.closeStdIn()).rejects.toThrow(RunloopError); + }); + }); +}); diff --git a/tests/smoketests/object-oriented/execution.test.ts b/tests/smoketests/object-oriented/execution.test.ts index 51b49bfa1..4f87ea057 100644 --- a/tests/smoketests/object-oriented/execution.test.ts +++ b/tests/smoketests/object-oriented/execution.test.ts @@ -89,33 +89,24 @@ describe('smoketest: object-oriented execution', () => { } }); - test.skip('start execution with stdin enabled', async () => { + test('start execution with stdin enabled', async () => { expect(devbox).toBeDefined(); execution = await devbox.cmd.execAsync('cat', { attach_stdin: true, }); expect(execution).toBeDefined(); expect(execution.executionId).toBeTruthy(); - expect((await execution.getState()).status).toBe('running'); + expect((await execution.getState()).status).not.toBe('completed'); }); - test.skip('send input to execution', async () => { + test('send input to execution', async () => { expect(execution).toBeDefined(); - expect((await execution.getState()).status).toBe('running'); - try { - //await execution.sendStdIn('Hello from stdin!\n'); - } catch (error) { - console.error('Error sending input to execution:', error); - } - - // Wait a bit for the input to be processed - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Kill the execution to get the result - await execution.kill(); + expect((await execution.getState()).status).not.toBe('completed'); + await execution.sendStdIn('Hello from stdin!\n'); + await execution.closeStdIn(); const result = await execution.result(); - expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); const output = await result.stdout(); expect(output).toContain('Hello from stdin!');