diff --git a/src/objects/execution-result.ts b/src/objects/execution-result.ts index a1d81bb89..2c4e06511 100644 --- a/src/objects/execution-result.ts +++ b/src/objects/execution-result.ts @@ -30,27 +30,104 @@ export class ExecutionResult { } /** - * Get the stdout output from the execution. + * Helper to get last N lines, filtering out trailing empty strings + */ + private getLastNLines(text: string, n: number): string { + if (n <= 0) { + return ''; + } + const lines = text.split('\n'); + // Remove trailing empty strings (from trailing newlines) + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + return lines.slice(-n).join('\n'); + } + + /** + * Helper to count non-empty lines (excluding trailing empty strings) + */ + private countNonEmptyLines(text: string): number { + const countLines = text.split('\n'); + // Remove trailing empty strings first + const trimmedLines = [...countLines]; + while (trimmedLines.length > 0 && trimmedLines[trimmedLines.length - 1] === '') { + trimmedLines.pop(); + } + // Filter out all empty strings (including those in the middle) + return trimmedLines.filter((line) => line !== '').length; + } + + /** + * Common logic for getting output (stdout or stderr) with optional line limiting + */ + private async getOutput( + currentOutput: string, + isOutputTruncated: boolean, + numLines: number | undefined, + streamFn: () => Promise>, + ): Promise { + // If numLines is specified, check if we have enough lines already + if (numLines !== undefined) { + const nonEmptyCount = this.countNonEmptyLines(currentOutput); + if (!isOutputTruncated || nonEmptyCount >= numLines) { + // We have enough lines, return the last N lines + return this.getLastNLines(currentOutput, numLines); + } + } + + // If output is truncated and we need all lines (or more than available), stream all logs + if (isOutputTruncated) { + const stream = await streamFn(); + let output = ''; + for await (const chunk of stream) { + output += chunk.output; + } + + // If numLines was specified, return only the last N lines + if (numLines !== undefined) { + return this.getLastNLines(output, numLines); + } + return output; + } + + // Output is not truncated, return what we have + if (numLines !== undefined) { + return this.getLastNLines(currentOutput, numLines); + } + return currentOutput; + } + + /** + * Get the stdout output from the execution. If numLines is specified, it will return the last N lines. If numLines is not specified, it will return the entire stdout output. + * Note after the execution is completed, the stdout is not available anymore. * - * @param numLines - Optional number of lines to return (for future pagination support) + * @param numLines - Optional number of lines to return from the end (most recent logs) * @returns The stdout content */ async stdout(numLines?: number): Promise { - // For now, just return the stdout from the result - // In the future, this will support pagination when output is truncated - return this._result.stdout ?? ''; + const currentStdout = this._result.stdout ?? ''; + const isOutputTruncated = this._result.stdout_truncated === true; + + return this.getOutput(currentStdout, isOutputTruncated, numLines, () => + this.client.devboxes.executions.streamStdoutUpdates(this._devboxId, this._executionId), + ); } /** - * Get the stderr output from the execution. + * Get the stderr output from the execution. If numLines is specified, it will return the last N lines. If numLines is not specified, it will return the entire stderr output. + * Note after the execution is completed, the stderr is not available anymore. * - * @param numLines - Optional number of lines to return (for future pagination support) + * @param numLines - Optional number of lines to guarantee from the end (most recent logs) * @returns The stderr content */ async stderr(numLines?: number): Promise { - // For now, just return the stderr from the result - // In the future, this will collecting all of the stderr output until the execution completes. If output is truncated, it will return the truncated output. - return this._result.stderr ?? ''; + const currentStderr = this._result.stderr ?? ''; + const isOutputTruncated = this._result.stderr_truncated === true; + + return this.getOutput(currentStderr, isOutputTruncated, numLines, () => + this.client.devboxes.executions.streamStderrUpdates(this._devboxId, this._executionId), + ); } /** diff --git a/tests/objects/execution-result.test.ts b/tests/objects/execution-result.test.ts new file mode 100644 index 000000000..edbd9b28f --- /dev/null +++ b/tests/objects/execution-result.test.ts @@ -0,0 +1,441 @@ +import { ExecutionResult } from '../../src/objects/execution-result'; +import type { DevboxAsyncExecutionDetailView } from '../../src/resources/devboxes/devboxes'; + +// Mock the Runloop client +jest.mock('../../src/index'); + +describe('ExecutionResult', () => { + let mockClient: any; + let mockExecutionData: DevboxAsyncExecutionDetailView; + + beforeEach(() => { + // Create mock client with streaming methods + mockClient = { + devboxes: { + executions: { + streamStdoutUpdates: jest.fn(), + streamStderrUpdates: jest.fn(), + }, + }, + } as any; + + // Mock execution data + mockExecutionData = { + devbox_id: 'devbox-123', + execution_id: 'exec-456', + status: 'completed', + exit_status: 0, + stdout: 'line1\nline2\nline3', + stderr: '', + }; + }); + + describe('constructor and basic properties', () => { + it('should create an ExecutionResult instance', () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + expect(result).toBeInstanceOf(ExecutionResult); + }); + + it('should expose exitCode property', () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + expect(result.exitCode).toBe(0); + }); + + it('should return null for exitCode if not present', () => { + const dataWithoutExit = { ...mockExecutionData, exit_status: null }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', dataWithoutExit); + + expect(result.exitCode).toBeNull(); + }); + + it('should expose raw result', () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + expect(result.result).toEqual(mockExecutionData); + }); + }); + + describe('success and failed properties', () => { + it('should return true for success when exit code is 0', () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + expect(result.success).toBe(true); + expect(result.failed).toBe(false); + }); + + it('should return true for failed when exit code is non-zero', () => { + const failedData = { ...mockExecutionData, exit_status: 1 }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', failedData); + + expect(result.success).toBe(false); + expect(result.failed).toBe(true); + }); + + it('should handle null exit code', () => { + const dataWithoutExit = { ...mockExecutionData, exit_status: null }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', dataWithoutExit); + + expect(result.success).toBe(false); + expect(result.failed).toBe(false); + }); + }); + + describe('stdout - non-truncated output', () => { + it('should return all stdout when not truncated and no numLines specified', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stdout = await result.stdout(); + + expect(stdout).toBe('line1\nline2\nline3'); + expect(mockClient.devboxes.executions.streamStdoutUpdates).not.toHaveBeenCalled(); + }); + + it('should return last N lines when not truncated and numLines specified', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stdout = await result.stdout(2); + + expect(stdout).toBe('line2\nline3'); + expect(mockClient.devboxes.executions.streamStdoutUpdates).not.toHaveBeenCalled(); + }); + + it('should return all lines when numLines exceeds available lines', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stdout = await result.stdout(10); + + expect(stdout).toBe('line1\nline2\nline3'); + expect(mockClient.devboxes.executions.streamStdoutUpdates).not.toHaveBeenCalled(); + }); + + it('should handle empty stdout', async () => { + const emptyData = { ...mockExecutionData, stdout: '' }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', emptyData); + + const stdout = await result.stdout(); + + expect(stdout).toBe(''); + }); + + it('should handle null stdout', async () => { + const nullData = { ...mockExecutionData, stdout: null }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', nullData); + + const stdout = await result.stdout(); + + expect(stdout).toBe(''); + }); + }); + + describe('stdout - truncated output', () => { + beforeEach(() => { + // Mock truncated execution data + mockExecutionData = { + ...mockExecutionData, + stdout: 'line1\nline2', + stdout_truncated: true, + } as any; + }); + + it('should stream all logs when truncated and no numLines specified', async () => { + const mockStream = [{ output: 'line1\nline2\n' }, { output: 'line3\nline4\n' }, { output: 'line5' }]; + + mockClient.devboxes.executions.streamStdoutUpdates.mockResolvedValue( + (async function* () { + for (const chunk of mockStream) { + yield chunk; + } + })(), + ); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stdout = await result.stdout(); + + expect(mockClient.devboxes.executions.streamStdoutUpdates).toHaveBeenCalledWith( + 'devbox-123', + 'exec-456', + ); + expect(stdout).toBe('line1\nline2\nline3\nline4\nline5'); + }); + + it('should stream and return last N lines when truncated and numLines specified', async () => { + const mockStream = [{ output: 'line1\nline2\n' }, { output: 'line3\nline4\n' }, { output: 'line5' }]; + + mockClient.devboxes.executions.streamStdoutUpdates.mockResolvedValue( + (async function* () { + for (const chunk of mockStream) { + yield chunk; + } + })(), + ); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stdout = await result.stdout(3); + + expect(mockClient.devboxes.executions.streamStdoutUpdates).toHaveBeenCalledWith( + 'devbox-123', + 'exec-456', + ); + expect(stdout).toBe('line3\nline4\nline5'); + }); + + it('should NOT stream when numLines is less than available lines', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stdout = await result.stdout(1); + + expect(mockClient.devboxes.executions.streamStdoutUpdates).not.toHaveBeenCalled(); + expect(stdout).toBe('line2'); + }); + + it('should stream when numLines exceeds available truncated lines', async () => { + const mockStream = [{ output: 'line1\nline2\nline3\nline4\nline5' }]; + + mockClient.devboxes.executions.streamStdoutUpdates.mockResolvedValue( + (async function* () { + for (const chunk of mockStream) { + yield chunk; + } + })(), + ); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stdout = await result.stdout(5); + + expect(mockClient.devboxes.executions.streamStdoutUpdates).toHaveBeenCalledWith( + 'devbox-123', + 'exec-456', + ); + expect(stdout).toBe('line1\nline2\nline3\nline4\nline5'); + }); + }); + + describe('stderr - non-truncated output', () => { + beforeEach(() => { + mockExecutionData = { + ...mockExecutionData, + stderr: 'error1\nerror2\nerror3', + }; + }); + + it('should return all stderr when not truncated and no numLines specified', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stderr = await result.stderr(); + + expect(stderr).toBe('error1\nerror2\nerror3'); + expect(mockClient.devboxes.executions.streamStderrUpdates).not.toHaveBeenCalled(); + }); + + it('should return last N lines when not truncated and numLines specified', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stderr = await result.stderr(2); + + expect(stderr).toBe('error2\nerror3'); + expect(mockClient.devboxes.executions.streamStderrUpdates).not.toHaveBeenCalled(); + }); + + it('should return all lines when numLines exceeds available lines', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stderr = await result.stderr(10); + + expect(stderr).toBe('error1\nerror2\nerror3'); + expect(mockClient.devboxes.executions.streamStderrUpdates).not.toHaveBeenCalled(); + }); + + it('should handle empty stderr', async () => { + const emptyData = { ...mockExecutionData, stderr: '' }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', emptyData); + + const stderr = await result.stderr(); + + expect(stderr).toBe(''); + }); + + it('should handle null stderr', async () => { + const nullData = { ...mockExecutionData, stderr: null }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', nullData); + + const stderr = await result.stderr(); + + expect(stderr).toBe(''); + }); + }); + + describe('stderr - truncated output', () => { + beforeEach(() => { + mockExecutionData = { + ...mockExecutionData, + stderr: 'error1\nerror2', + stderr_truncated: true, + } as any; + }); + + it('should stream all logs when truncated and no numLines specified', async () => { + const mockStream = [ + { output: 'error1\nerror2\n' }, + { output: 'error3\nerror4\n' }, + { output: 'error5' }, + ]; + + mockClient.devboxes.executions.streamStderrUpdates.mockResolvedValue( + (async function* () { + for (const chunk of mockStream) { + yield chunk; + } + })(), + ); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stderr = await result.stderr(); + + expect(mockClient.devboxes.executions.streamStderrUpdates).toHaveBeenCalledWith( + 'devbox-123', + 'exec-456', + ); + expect(stderr).toBe('error1\nerror2\nerror3\nerror4\nerror5'); + }); + + it('should stream and return last N lines when truncated and numLines specified', async () => { + const mockStream = [ + { output: 'error1\nerror2\n' }, + { output: 'error3\nerror4\n' }, + { output: 'error5' }, + ]; + + mockClient.devboxes.executions.streamStderrUpdates.mockResolvedValue( + (async function* () { + for (const chunk of mockStream) { + yield chunk; + } + })(), + ); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stderr = await result.stderr(3); + + expect(mockClient.devboxes.executions.streamStderrUpdates).toHaveBeenCalledWith( + 'devbox-123', + 'exec-456', + ); + expect(stderr).toBe('error3\nerror4\nerror5'); + }); + + it('should NOT stream when numLines is less than available lines', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stderr = await result.stderr(1); + + expect(mockClient.devboxes.executions.streamStderrUpdates).not.toHaveBeenCalled(); + expect(stderr).toBe('error2'); + }); + + it('should stream when numLines exceeds available truncated lines', async () => { + const mockStream = [{ output: 'error1\nerror2\nerror3\nerror4\nerror5' }]; + + mockClient.devboxes.executions.streamStderrUpdates.mockResolvedValue( + (async function* () { + for (const chunk of mockStream) { + yield chunk; + } + })(), + ); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + const stderr = await result.stderr(5); + + expect(mockClient.devboxes.executions.streamStderrUpdates).toHaveBeenCalledWith( + 'devbox-123', + 'exec-456', + ); + expect(stderr).toBe('error1\nerror2\nerror3\nerror4\nerror5'); + }); + }); + + describe('edge cases', () => { + it('should handle single-line output', async () => { + const singleLineData = { ...mockExecutionData, stdout: 'single line' }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', singleLineData); + + const stdout = await result.stdout(); + + expect(stdout).toBe('single line'); + }); + + it('should handle output with trailing newline', async () => { + const trailingNewlineData = { ...mockExecutionData, stdout: 'line1\nline2\nline3\n' }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', trailingNewlineData); + + const stdout = await result.stdout(2); + + // Should return the last 2 actual lines (trailing empty strings are removed) + expect(stdout).toBe('line2\nline3'); + }); + + it('should handle numLines = 0', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stdout = await result.stdout(0); + + // When numLines is 0, it should return an empty string + expect(stdout).toBe(''); + }); + + it('should handle numLines = 1 (last line only)', async () => { + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', mockExecutionData); + + const stdout = await result.stdout(1); + + expect(stdout).toBe('line3'); + }); + + it('should handle concurrent stdout and stderr calls', async () => { + const dataWithBoth = { + ...mockExecutionData, + stdout: 'out1\nout2', + stderr: 'err1\nerr2', + }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', dataWithBoth); + + const [stdout, stderr] = await Promise.all([result.stdout(), result.stderr()]); + + expect(stdout).toBe('out1\nout2'); + expect(stderr).toBe('err1\nerr2'); + }); + + it('should handle large output with many lines', async () => { + const largeOutput = Array.from({ length: 1000 }, (_, i) => `line${i}`).join('\n'); + const largeData = { ...mockExecutionData, stdout: largeOutput }; + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', largeData); + + const stdout = await result.stdout(50); + + const lines = stdout.split('\n'); + expect(lines).toHaveLength(50); + expect(lines[0]).toBe('line950'); + expect(lines[49]).toBe('line999'); + }); + }); + + describe('streaming error handling', () => { + it('should propagate errors from streamStdoutUpdates', async () => { + const truncatedData = { ...mockExecutionData, stdout_truncated: true } as any; + mockClient.devboxes.executions.streamStdoutUpdates.mockRejectedValue(new Error('Stream failed')); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', truncatedData); + + await expect(result.stdout()).rejects.toThrow('Stream failed'); + }); + + it('should propagate errors from streamStderrUpdates', async () => { + const truncatedData = { ...mockExecutionData, stderr_truncated: true } as any; + mockClient.devboxes.executions.streamStderrUpdates.mockRejectedValue(new Error('Stream failed')); + + const result = new ExecutionResult(mockClient, 'devbox-123', 'exec-456', truncatedData); + + await expect(result.stderr()).rejects.toThrow('Stream failed'); + }); + }); +}); diff --git a/tests/smoketests/executions.test.ts b/tests/smoketests/executions.test.ts index f0156a468..be6fceffa 100644 --- a/tests/smoketests/executions.test.ts +++ b/tests/smoketests/executions.test.ts @@ -50,6 +50,26 @@ describe('smoketest: executions', () => { expect(typeof received).toBe('string'); }); + test('tail stderr logs', async () => { + // Create a new execution that produces stderr output + const stderrExec = await client.devboxes.executions.executeAsync(devboxId!, { + command: 'echo "error output" >&2 && sleep 1', + }); + const stderrExecId = stderrExec.execution_id; + await client.devboxes.executions.awaitCompleted(devboxId!, stderrExecId, { + polling: { maxAttempts: 120, pollingIntervalMs: 2_000, timeoutMs: 10 * 60 * 1000 }, + }); + + const stream = await client.devboxes.executions.streamStderrUpdates(devboxId!, stderrExecId, {}); + let received = ''; + for await (const chunk of stream) { + received += chunk.output; + if (received.length > 0) break; // stop early to avoid long loops in CI + } + expect(typeof received).toBe('string'); + expect(received).toContain('error output'); + }); + test('executeAndAwaitCompletion', async () => { const completed = await client.devboxes.executeAndAwaitCompletion(devboxId!, { command: 'echo hello && sleep 1', @@ -139,4 +159,47 @@ describe('smoketest: executions', () => { expect(outputLines[3]).toBe('line 4'); expect(outputLines[4]).toBe('line 5'); }); + + test('executeAndAwaitCompletion with stderr output', async () => { + // This test verifies that stderr output is captured + const completed = await client.devboxes.executeAndAwaitCompletion(devboxId!, { + command: 'echo "Error message" >&2', + }); + + expect(completed.status).toBe('completed'); + expect(completed.stderr).toBeDefined(); + expect(completed.stderr).toContain('Error message'); + }); + + test('executeAndAwaitCompletion with multiple stderr lines', async () => { + // This test verifies that multiple lines of stderr are captured + const completed = await client.devboxes.executeAndAwaitCompletion(devboxId!, { + command: 'echo "error 1" >&2 && echo "error 2" >&2 && echo "error 3" >&2', + }); + + expect(completed.status).toBe('completed'); + expect(completed.stderr).toBeDefined(); + + const errorLines = completed.stderr?.trim().split('\n') || []; + expect(errorLines.length).toBeGreaterThanOrEqual(3); + expect(completed.stderr).toContain('error 1'); + expect(completed.stderr).toContain('error 2'); + expect(completed.stderr).toContain('error 3'); + }); + + test('executeAndAwaitCompletion with both stdout and stderr', async () => { + // This test verifies that both stdout and stderr are captured separately + const completed = await client.devboxes.executeAndAwaitCompletion(devboxId!, { + command: 'echo "stdout message" && echo "stderr message" >&2', + }); + + expect(completed.status).toBe('completed'); + expect(completed.stdout).toBeDefined(); + expect(completed.stderr).toBeDefined(); + expect(completed.stdout).toContain('stdout message'); + expect(completed.stderr).toContain('stderr message'); + // Verify they are separate + expect(completed.stdout).not.toContain('stderr message'); + expect(completed.stderr).not.toContain('stdout message'); + }); }); diff --git a/tests/smoketests/object-oriented/blueprint.test.ts b/tests/smoketests/object-oriented/blueprint.test.ts index 684659805..90ef8cd59 100644 --- a/tests/smoketests/object-oriented/blueprint.test.ts +++ b/tests/smoketests/object-oriented/blueprint.test.ts @@ -1,14 +1,7 @@ -import { RunloopSDK } from '@runloop/api-client'; -import { makeClient, THIRTY_SECOND_TIMEOUT, uniqueName } from '../utils'; +import { THIRTY_SECOND_TIMEOUT, uniqueName, makeClientSDK } from '../utils'; import { Blueprint, Devbox } from '@runloop/api-client/objects'; -const client = makeClient(); -const sdk = new RunloopSDK({ - bearerToken: process.env['RUNLOOP_API_KEY'], - baseURL: process.env['RUNLOOP_BASE_URL'], - timeout: 120_000, - maxRetries: 1, -}); +const sdk = makeClientSDK(); describe('smoketest: object-oriented blueprint', () => { describe('blueprint lifecycle', () => { diff --git a/tests/smoketests/object-oriented/devbox.test.ts b/tests/smoketests/object-oriented/devbox.test.ts index f0ece2e9f..370e76bd8 100644 --- a/tests/smoketests/object-oriented/devbox.test.ts +++ b/tests/smoketests/object-oriented/devbox.test.ts @@ -1,14 +1,8 @@ -import { RunloopSDK, toFile } from '@runloop/api-client'; +import { toFile } from '@runloop/api-client'; import { Devbox } from '@runloop/api-client/objects'; -import { makeClient, THIRTY_SECOND_TIMEOUT, uniqueName } from '../utils'; - -const client = makeClient(); -const sdk = new RunloopSDK({ - bearerToken: process.env['RUNLOOP_API_KEY'], - baseURL: process.env['RUNLOOP_BASE_URL'], - timeout: 120_000, - maxRetries: 1, -}); +import { makeClientSDK, THIRTY_SECOND_TIMEOUT, uniqueName } from '../utils'; + +const sdk = makeClientSDK(); describe('smoketest: object-oriented devbox', () => { describe('devbox lifecycle', () => { @@ -507,8 +501,7 @@ describe('smoketest: object-oriented devbox', () => { } // Verify streaming captured same data as result - // TODO(alex): Run this test after we've enabled pagination for ExecutionResult.stdout() - // expect(stdoutCombined).toBe(await result.stdout()); + expect(stdoutCombined).toBe(await result.stdout()); }); test('concurrent execAsync - multiple executions streaming simultaneously', async () => { diff --git a/tests/smoketests/object-oriented/execution.test.ts b/tests/smoketests/object-oriented/execution.test.ts index 0cb61700b..441566f90 100644 --- a/tests/smoketests/object-oriented/execution.test.ts +++ b/tests/smoketests/object-oriented/execution.test.ts @@ -1,14 +1,7 @@ -import { RunloopSDK } from '@runloop/api-client'; import { Devbox, Execution } from '@runloop/api-client/objects'; -import { makeClient, uniqueName } from '../utils'; - -const client = makeClient(); -const sdk = new RunloopSDK({ - bearerToken: process.env['RUNLOOP_API_KEY'], - baseURL: process.env['RUNLOOP_BASE_URL'], - timeout: 120_000, - maxRetries: 1, -}); +import { makeClientSDK, uniqueName } from '../utils'; + +const sdk = makeClientSDK(); describe('smoketest: object-oriented execution', () => { describe('execution lifecycle', () => { @@ -166,14 +159,40 @@ describe('smoketest: object-oriented execution', () => { test('handle execution with stderr output', async () => { expect(devbox).toBeDefined(); + // Generate 1000 lines to stderr to test large output handling const result = await devbox.cmd.exec({ - command: 'echo "Error message" >&2', + command: 'for i in {1..1000}; do echo "Error message $i" >&2; done', }); expect(result).toBeDefined(); expect(result.exitCode).toBe(0); - const stderr = await result.stderr(); - expect(stderr).toContain('Error message'); + // Get all stderr output + const allStderr = await result.stderr(); + expect(allStderr).toBeDefined(); + expect(typeof allStderr).toBe('string'); + + // Verify it contains expected error messages + expect(allStderr).toContain('Error message 1'); + expect(allStderr).toContain('Error message 1000'); + + // Get last 10 lines to test numLines parameter + const last10Lines = await result.stderr(10); + const last10LinesArray = last10Lines.split('\n').filter((line) => line.trim().length > 0); + + // Should contain the last error messages + expect(last10Lines).toContain('Error message 1000'); + expect(last10LinesArray.length).toBeGreaterThanOrEqual(1); + + // Verify it's the last lines, not the first (check actual error numbers) + const errorNumbers = last10LinesArray + .map((line) => { + const match = line.match(/Error message (\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; + }) + .filter((num): num is number => num !== null); + expect(errorNumbers.length).toBeGreaterThan(0); + expect(Math.min(...errorNumbers)).toBeGreaterThanOrEqual(991); + expect(Math.max(...errorNumbers)).toBeLessThanOrEqual(1000); }); test('handle execution with no output', async () => { @@ -191,4 +210,238 @@ describe('smoketest: object-oriented execution', () => { expect(typeof stderr).toBe('string'); }); }); + + describe('execution output with numLines', () => { + let devbox: Devbox; + + beforeAll(async () => { + // Create a devbox first + devbox = await sdk.devbox.create({ + name: uniqueName('sdk-devbox-execution-numlines'), + launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 60 * 5 }, // 5 minutes + }); + expect(devbox).toBeDefined(); + }); + + afterAll(async () => { + if (devbox) { + await devbox.shutdown(); + } + }); + + test('get last N lines from stdout', async () => { + expect(devbox).toBeDefined(); + // Generate output with multiple lines + const result = await devbox.cmd.exec({ + command: 'for i in {1..10}; do echo "Line $i"; done', + }); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + + // Get all stdout + const allStdout = await result.stdout(); + expect(allStdout).toContain('Line 1'); + expect(allStdout).toContain('Line 10'); + + // Get last 3 lines + const last3Lines = await result.stdout(3); + const lines = last3Lines.split('\n').filter((line) => line.trim().length > 0); + // Should have exactly 3 lines + expect(lines.length).toBeGreaterThanOrEqual(3); + // Must contain all last 3 lines + expect(last3Lines).toContain('Line 10'); + expect(last3Lines).toContain('Line 9'); + expect(last3Lines).toContain('Line 8'); + // Should not contain earlier lines (check actual line numbers, not substrings) + const lineNumbers = lines + .map((line) => { + const match = line.match(/Line (\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; + }) + .filter((num): num is number => num !== null); + expect(lineNumbers.length).toBeGreaterThan(0); + expect(Math.min(...lineNumbers)).toBeGreaterThanOrEqual(8); + expect(Math.max(...lineNumbers)).toBeLessThanOrEqual(10); + }); + + test('get last N lines from stderr', async () => { + expect(devbox).toBeDefined(); + // Generate stderr output with multiple lines + const result = await devbox.cmd.exec({ + command: 'for i in {1..10}; do echo "Error $i" >&2; done', + }); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + + // Get all stderr + const allStderr = await result.stderr(); + expect(allStderr).toContain('Error 1'); + expect(allStderr).toContain('Error 10'); + + // Get last 3 lines + const last3Lines = await result.stderr(3); + // Must contain all last 3 lines + expect(last3Lines).toContain('Error 10'); + expect(last3Lines).toContain('Error 9'); + expect(last3Lines).toContain('Error 8'); + // Should not contain earlier errors (check actual error numbers, not substrings) + const errorLines = last3Lines.split('\n').filter((line) => line.trim().length > 0); + const errorNumbers = errorLines + .map((line) => { + const match = line.match(/Error (\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; + }) + .filter((num): num is number => num !== null); + expect(errorNumbers.length).toBeGreaterThan(0); + expect(Math.min(...errorNumbers)).toBeGreaterThanOrEqual(8); + expect(Math.max(...errorNumbers)).toBeLessThanOrEqual(10); + }); + + test('get last 1 line (most recent)', async () => { + expect(devbox).toBeDefined(); + const result = await devbox.cmd.exec({ + command: 'echo "First line"; echo "Second line"; echo "Last line"', + }); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + + // Get all output first to verify it exists + const allOutput = await result.stdout(); + expect(allOutput).toContain('Last line'); + + const lastLine = await result.stdout(1); + // Should contain the last line (might have trailing newline) + expect(lastLine.trim()).toContain('Last line'); + expect(lastLine).not.toContain('First line'); + expect(lastLine).not.toContain('Second line'); + }); + + test('get all lines when numLines exceeds available lines', async () => { + expect(devbox).toBeDefined(); + const result = await devbox.cmd.exec({ + command: 'echo "Line 1"; echo "Line 2"', + }); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + + // Request more lines than available + const output = await result.stdout(100); + expect(output).toContain('Line 1'); + expect(output).toContain('Line 2'); + }); + + test('get last N lines from both stdout and stderr', async () => { + expect(devbox).toBeDefined(); + const result = await devbox.cmd.exec({ + command: 'for i in {1..5}; do echo "Out $i"; echo "Err $i" >&2; done', + }); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + + const last2Stdout = await result.stdout(2); + const last2Stderr = await result.stderr(2); + + // Verify stdout contains last 2 lines + expect(last2Stdout).toContain('Out 5'); + expect(last2Stdout).toContain('Out 4'); + // Check actual line numbers to avoid substring matches + const stdoutLines = last2Stdout.split('\n').filter((line) => line.trim().length > 0); + const stdoutNumbers = stdoutLines + .map((line) => { + const match = line.match(/Out (\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; + }) + .filter((num): num is number => num !== null); + expect(stdoutNumbers.length).toBeGreaterThan(0); + expect(Math.min(...stdoutNumbers)).toBeGreaterThanOrEqual(4); + expect(Math.max(...stdoutNumbers)).toBeLessThanOrEqual(5); + + // Verify stderr contains last 2 lines + expect(last2Stderr).toContain('Err 5'); + expect(last2Stderr).toContain('Err 4'); + const stderrLines = last2Stderr.split('\n').filter((line) => line.trim().length > 0); + const stderrNumbers = stderrLines + .map((line) => { + const match = line.match(/Err (\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; + }) + .filter((num): num is number => num !== null); + expect(stderrNumbers.length).toBeGreaterThan(0); + expect(Math.min(...stderrNumbers)).toBeGreaterThanOrEqual(4); + expect(Math.max(...stderrNumbers)).toBeLessThanOrEqual(5); + }); + + test('handle truncated output with last N lines', async () => { + expect(devbox).toBeDefined(); + // Generate 1000 lines to trigger truncation + const result = await devbox.cmd.exec({ + command: 'for i in {1..1000}; do echo "Line $i"; done', + }); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + + // Check if output was truncated (it should be with 1000 lines) + const allStdout = await result.stdout(); + const allLines = allStdout.split('\n').filter((line) => line.trim().length > 0); + + // If truncated, we should get fewer than 1000 lines initially + // Try to get last 10 lines - should work whether truncated or not + const last10Lines = await result.stdout(10); + const last10LinesArray = last10Lines.split('\n').filter((line) => line.trim().length > 0); + + // Should contain the last line + expect(last10Lines).toContain('Line 1000'); + expect(last10LinesArray.length).toBeGreaterThanOrEqual(1); // At least 1 line + + // If we got all lines (not truncated), verify we have the last lines + if (allLines.length >= 1000) { + expect(last10LinesArray.length).toBeLessThanOrEqual(10); + // Should contain lines 991-1000 + expect(last10Lines).toContain('Line 991'); + expect(last10Lines).toContain('Line 1000'); + // Check actual line numbers to avoid substring matches + const lineNumbers = last10LinesArray + .map((line) => { + const match = line.match(/Line (\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; + }) + .filter((num): num is number => num !== null); + expect(lineNumbers.length).toBeGreaterThan(0); + expect(Math.min(...lineNumbers)).toBeGreaterThanOrEqual(991); + expect(Math.max(...lineNumbers)).toBeLessThanOrEqual(1000); + } else { + // Truncated case - verify we still get the last available lines + expect(last10LinesArray.length).toBeLessThanOrEqual(allLines.length); + } + }); + + test('handle truncated stderr with last N lines', async () => { + expect(devbox).toBeDefined(); + // Generate 1000 lines to stderr to trigger truncation + const result = await devbox.cmd.exec({ + command: 'for i in {1..1000}; do echo "Error $i" >&2; done', + }); + expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); + + // Get last 10 lines from stderr + const last10Lines = await result.stderr(10); + const last10LinesArray = last10Lines.split('\n').filter((line) => line.trim().length > 0); + + // Should contain the last error + expect(last10Lines).toContain('Error 1000'); + expect(last10LinesArray.length).toBeGreaterThanOrEqual(1); + + // Verify it's the last lines, not the first (check actual error numbers) + const errorNumbers = last10LinesArray + .map((line) => { + const match = line.match(/Error (\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; + }) + .filter((num): num is number => num !== null); + expect(errorNumbers.length).toBeGreaterThan(0); + expect(Math.min(...errorNumbers)).toBeGreaterThanOrEqual(991); + expect(Math.max(...errorNumbers)).toBeLessThanOrEqual(1000); + }); + }); }); diff --git a/tests/smoketests/object-oriented/sdk.test.ts b/tests/smoketests/object-oriented/sdk.test.ts index bbe064b1c..c7c5ce2c6 100644 --- a/tests/smoketests/object-oriented/sdk.test.ts +++ b/tests/smoketests/object-oriented/sdk.test.ts @@ -1,13 +1,6 @@ -import { RunloopSDK } from '@runloop/api-client'; -import { makeClient } from '../utils'; +import { makeClientSDK } from '../utils'; -const client = makeClient(); -const sdk = new RunloopSDK({ - bearerToken: process.env['RUNLOOP_API_KEY'], - baseURL: process.env['RUNLOOP_BASE_URL'], - timeout: 120_000, - maxRetries: 1, -}); +const sdk = makeClientSDK(); describe('smoketest: object-oriented SDK', () => { describe('RunloopSDK initialization', () => { diff --git a/tests/smoketests/object-oriented/snapshot.test.ts b/tests/smoketests/object-oriented/snapshot.test.ts index dc5bb2228..480d55466 100644 --- a/tests/smoketests/object-oriented/snapshot.test.ts +++ b/tests/smoketests/object-oriented/snapshot.test.ts @@ -1,14 +1,7 @@ -import { RunloopSDK } from '@runloop/api-client'; import { Devbox, Snapshot } from '@runloop/api-client/objects'; -import { makeClient, uniqueName } from '../utils'; - -const client = makeClient(); -const sdk = new RunloopSDK({ - bearerToken: process.env['RUNLOOP_API_KEY'], - baseURL: process.env['RUNLOOP_BASE_URL'], - timeout: 120_000, - maxRetries: 1, -}); +import { makeClientSDK, uniqueName } from '../utils'; + +const sdk = makeClientSDK(); describe('smoketest: object-oriented snapshot', () => { describe('snapshot operations', () => { @@ -57,6 +50,7 @@ describe('smoketest: object-oriented snapshot', () => { expect(asyncSnapshot.id).toBeTruthy(); } finally { if (asyncSnapshot) { + await asyncSnapshot.awaitCompleted(); await asyncSnapshot.delete(); } } diff --git a/tests/smoketests/object-oriented/storage-object.test.ts b/tests/smoketests/object-oriented/storage-object.test.ts index 9cd631f0a..a7eff2165 100644 --- a/tests/smoketests/object-oriented/storage-object.test.ts +++ b/tests/smoketests/object-oriented/storage-object.test.ts @@ -1,14 +1,7 @@ -import { RunloopSDK } from '@runloop/api-client'; -import { makeClient, THIRTY_SECOND_TIMEOUT, uniqueName } from '../utils'; +import { THIRTY_SECOND_TIMEOUT, uniqueName, makeClientSDK } from '../utils'; import { Devbox, StorageObject } from '@runloop/api-client/objects'; -const client = makeClient(); -const sdk = new RunloopSDK({ - bearerToken: process.env['RUNLOOP_API_KEY'], - baseURL: process.env['RUNLOOP_BASE_URL'], - timeout: 120_000, - maxRetries: 1, -}); +const sdk = makeClientSDK(); describe('smoketest: object-oriented storage object', () => { describe('storage object lifecycle', () => { @@ -240,7 +233,7 @@ describe('smoketest: object-oriented storage object', () => { test('list storage objects via static method', async () => { const { StorageObject } = await import('@runloop/api-client/objects'); - const objects = await StorageObject.list(client, { limit: 5 }); + const objects = await StorageObject.list(sdk.api, { limit: 5 }); expect(Array.isArray(objects)).toBe(true); }); diff --git a/tests/smoketests/utils.ts b/tests/smoketests/utils.ts index f633d76ae..94d8c8c87 100644 --- a/tests/smoketests/utils.ts +++ b/tests/smoketests/utils.ts @@ -1,4 +1,4 @@ -import { Runloop } from '@runloop/api-client'; +import { Runloop, RunloopSDK } from '@runloop/api-client'; export function makeClient(overrides: Partial[0]> = {}) { const baseURL = process.env['RUNLOOP_BASE_URL']; @@ -13,6 +13,15 @@ export function makeClient(overrides: Partial `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;