mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-05 12:14:59 -05:00
* fix(sdk): improve input handling and separate input from options * fix(sdk): treat null as no input for consistency with Python SDK
652 lines
20 KiB
TypeScript
652 lines
20 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { SimStudioClient, SimStudioError } from './index'
|
|
|
|
vi.mock('node-fetch', () => ({
|
|
default: vi.fn(),
|
|
}))
|
|
|
|
describe('SimStudioClient', () => {
|
|
let client: SimStudioClient
|
|
|
|
beforeEach(() => {
|
|
client = new SimStudioClient({
|
|
apiKey: 'test-api-key',
|
|
baseUrl: 'https://test.sim.ai',
|
|
})
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('constructor', () => {
|
|
it('should create a client with correct configuration', () => {
|
|
expect(client).toBeInstanceOf(SimStudioClient)
|
|
})
|
|
|
|
it('should use default base URL when not provided', () => {
|
|
const defaultClient = new SimStudioClient({
|
|
apiKey: 'test-api-key',
|
|
})
|
|
expect(defaultClient).toBeInstanceOf(SimStudioClient)
|
|
})
|
|
})
|
|
|
|
describe('setApiKey', () => {
|
|
it('should update the API key', () => {
|
|
const newApiKey = 'new-api-key'
|
|
client.setApiKey(newApiKey)
|
|
|
|
// Verify the method exists
|
|
expect(client.setApiKey).toBeDefined()
|
|
// Verify the API key was actually updated
|
|
expect((client as any).apiKey).toBe(newApiKey)
|
|
})
|
|
})
|
|
|
|
describe('setBaseUrl', () => {
|
|
it('should update the base URL', () => {
|
|
const newBaseUrl = 'https://new.sim.ai'
|
|
client.setBaseUrl(newBaseUrl)
|
|
expect((client as any).baseUrl).toBe(newBaseUrl)
|
|
})
|
|
|
|
it('should strip trailing slash from base URL', () => {
|
|
const urlWithSlash = 'https://test.sim.ai/'
|
|
client.setBaseUrl(urlWithSlash)
|
|
// Verify the trailing slash was actually stripped
|
|
expect((client as any).baseUrl).toBe('https://test.sim.ai')
|
|
})
|
|
})
|
|
|
|
describe('validateWorkflow', () => {
|
|
it('should return false when workflow status request fails', async () => {
|
|
const fetch = await import('node-fetch')
|
|
vi.mocked(fetch.default).mockRejectedValue(new Error('Network error'))
|
|
|
|
const result = await client.validateWorkflow('test-workflow-id')
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should return true when workflow is deployed', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
isDeployed: true,
|
|
deployedAt: '2023-01-01T00:00:00Z',
|
|
needsRedeployment: false,
|
|
}),
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await client.validateWorkflow('test-workflow-id')
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
it('should return false when workflow is not deployed', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
isDeployed: false,
|
|
deployedAt: null,
|
|
needsRedeployment: true,
|
|
}),
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await client.validateWorkflow('test-workflow-id')
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('executeWorkflow - async execution', () => {
|
|
it('should return AsyncExecutionResult when async is true', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 202,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
taskId: 'task-123',
|
|
status: 'queued',
|
|
createdAt: '2024-01-01T00:00:00Z',
|
|
links: {
|
|
status: '/api/jobs/task-123',
|
|
},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await client.executeWorkflow(
|
|
'workflow-id',
|
|
{ message: 'Hello' },
|
|
{ async: true }
|
|
)
|
|
|
|
expect(result).toHaveProperty('taskId', 'task-123')
|
|
expect(result).toHaveProperty('status', 'queued')
|
|
expect(result).toHaveProperty('links')
|
|
expect((result as any).links.status).toBe('/api/jobs/task-123')
|
|
|
|
// Verify headers were set correctly
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
expect(calls[0][1]?.headers).toMatchObject({
|
|
'X-Execution-Mode': 'async',
|
|
})
|
|
})
|
|
|
|
it('should return WorkflowExecutionResult when async is false', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: { result: 'completed' },
|
|
logs: [],
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await client.executeWorkflow(
|
|
'workflow-id',
|
|
{ message: 'Hello' },
|
|
{ async: false }
|
|
)
|
|
|
|
expect(result).toHaveProperty('success', true)
|
|
expect(result).toHaveProperty('output')
|
|
expect(result).not.toHaveProperty('taskId')
|
|
})
|
|
|
|
it('should not set X-Execution-Mode header when async is undefined', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: {},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow('workflow-id', { message: 'Hello' })
|
|
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode')
|
|
})
|
|
})
|
|
|
|
describe('getJobStatus', () => {
|
|
it('should fetch job status with correct endpoint', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
taskId: 'task-123',
|
|
status: 'completed',
|
|
metadata: {
|
|
startedAt: '2024-01-01T00:00:00Z',
|
|
completedAt: '2024-01-01T00:01:00Z',
|
|
duration: 60000,
|
|
},
|
|
output: { result: 'done' },
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await client.getJobStatus('task-123')
|
|
|
|
expect(result).toHaveProperty('taskId', 'task-123')
|
|
expect(result).toHaveProperty('status', 'completed')
|
|
expect(result).toHaveProperty('output')
|
|
|
|
// Verify correct endpoint was called
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
expect(calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123')
|
|
})
|
|
|
|
it('should handle job not found error', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: vi.fn().mockResolvedValue({
|
|
error: 'Job not found',
|
|
code: 'JOB_NOT_FOUND',
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await expect(client.getJobStatus('invalid-task')).rejects.toThrow(SimStudioError)
|
|
await expect(client.getJobStatus('invalid-task')).rejects.toThrow('Job not found')
|
|
})
|
|
})
|
|
|
|
describe('executeWithRetry', () => {
|
|
it('should succeed on first attempt when no rate limit', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: { result: 'success' },
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await client.executeWithRetry('workflow-id', { message: 'test' })
|
|
|
|
expect(result).toHaveProperty('success', true)
|
|
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should retry on rate limit error', async () => {
|
|
const fetch = await import('node-fetch')
|
|
|
|
// First call returns 429, second call succeeds
|
|
const rateLimitResponse = {
|
|
ok: false,
|
|
status: 429,
|
|
statusText: 'Too Many Requests',
|
|
json: vi.fn().mockResolvedValue({
|
|
error: 'Rate limit exceeded',
|
|
code: 'RATE_LIMIT_EXCEEDED',
|
|
}),
|
|
headers: {
|
|
get: vi.fn((header: string) => {
|
|
if (header === 'retry-after') return '1'
|
|
if (header === 'x-ratelimit-limit') return '100'
|
|
if (header === 'x-ratelimit-remaining') return '0'
|
|
if (header === 'x-ratelimit-reset') return String(Math.floor(Date.now() / 1000) + 60)
|
|
return null
|
|
}),
|
|
},
|
|
}
|
|
|
|
const successResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: { result: 'success' },
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default)
|
|
.mockResolvedValueOnce(rateLimitResponse as any)
|
|
.mockResolvedValueOnce(successResponse as any)
|
|
|
|
const result = await client.executeWithRetry(
|
|
'workflow-id',
|
|
{ message: 'test' },
|
|
{},
|
|
{ maxRetries: 3, initialDelay: 10 }
|
|
)
|
|
|
|
expect(result).toHaveProperty('success', true)
|
|
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should throw after max retries exceeded', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: false,
|
|
status: 429,
|
|
statusText: 'Too Many Requests',
|
|
json: vi.fn().mockResolvedValue({
|
|
error: 'Rate limit exceeded',
|
|
code: 'RATE_LIMIT_EXCEEDED',
|
|
}),
|
|
headers: {
|
|
get: vi.fn((header: string) => {
|
|
if (header === 'retry-after') return '1'
|
|
return null
|
|
}),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await expect(
|
|
client.executeWithRetry(
|
|
'workflow-id',
|
|
{ message: 'test' },
|
|
{},
|
|
{ maxRetries: 2, initialDelay: 10 }
|
|
)
|
|
).rejects.toThrow('Rate limit exceeded')
|
|
|
|
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(3) // Initial + 2 retries
|
|
})
|
|
|
|
it('should not retry on non-rate-limit errors', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
json: vi.fn().mockResolvedValue({
|
|
error: 'Server error',
|
|
code: 'INTERNAL_ERROR',
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await expect(client.executeWithRetry('workflow-id', { message: 'test' })).rejects.toThrow(
|
|
'Server error'
|
|
)
|
|
|
|
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) // No retries
|
|
})
|
|
})
|
|
|
|
describe('getRateLimitInfo', () => {
|
|
it('should return null when no rate limit info available', () => {
|
|
const info = client.getRateLimitInfo()
|
|
expect(info).toBeNull()
|
|
})
|
|
|
|
it('should return rate limit info after API call', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({ success: true, output: {} }),
|
|
headers: {
|
|
get: vi.fn((header: string) => {
|
|
if (header === 'x-ratelimit-limit') return '100'
|
|
if (header === 'x-ratelimit-remaining') return '95'
|
|
if (header === 'x-ratelimit-reset') return '1704067200'
|
|
return null
|
|
}),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow('workflow-id', {})
|
|
|
|
const info = client.getRateLimitInfo()
|
|
expect(info).not.toBeNull()
|
|
expect(info?.limit).toBe(100)
|
|
expect(info?.remaining).toBe(95)
|
|
expect(info?.reset).toBe(1704067200)
|
|
})
|
|
})
|
|
|
|
describe('getUsageLimits', () => {
|
|
it('should fetch usage limits with correct structure', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
rateLimit: {
|
|
sync: {
|
|
isLimited: false,
|
|
limit: 100,
|
|
remaining: 95,
|
|
resetAt: '2024-01-01T01:00:00Z',
|
|
},
|
|
async: {
|
|
isLimited: false,
|
|
limit: 50,
|
|
remaining: 48,
|
|
resetAt: '2024-01-01T01:00:00Z',
|
|
},
|
|
authType: 'api',
|
|
},
|
|
usage: {
|
|
currentPeriodCost: 1.23,
|
|
limit: 100.0,
|
|
plan: 'pro',
|
|
},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await client.getUsageLimits()
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.rateLimit.sync.limit).toBe(100)
|
|
expect(result.rateLimit.async.limit).toBe(50)
|
|
expect(result.usage.currentPeriodCost).toBe(1.23)
|
|
expect(result.usage.plan).toBe('pro')
|
|
|
|
// Verify correct endpoint was called
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
expect(calls[0][0]).toBe('https://test.sim.ai/api/users/me/usage-limits')
|
|
})
|
|
|
|
it('should handle unauthorized error', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
json: vi.fn().mockResolvedValue({
|
|
error: 'Invalid API key',
|
|
code: 'UNAUTHORIZED',
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await expect(client.getUsageLimits()).rejects.toThrow(SimStudioError)
|
|
await expect(client.getUsageLimits()).rejects.toThrow('Invalid API key')
|
|
})
|
|
})
|
|
|
|
describe('executeWorkflow - streaming with selectedOutputs', () => {
|
|
it('should include stream and selectedOutputs in request body', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: {},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow(
|
|
'workflow-id',
|
|
{ message: 'test' },
|
|
{ stream: true, selectedOutputs: ['agent1.content', 'agent2.content'] }
|
|
)
|
|
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
|
|
|
expect(requestBody).toHaveProperty('message', 'test')
|
|
expect(requestBody).toHaveProperty('stream', true)
|
|
expect(requestBody).toHaveProperty('selectedOutputs')
|
|
expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content'])
|
|
})
|
|
})
|
|
|
|
describe('executeWorkflow - primitive and array inputs', () => {
|
|
it('should wrap primitive string input in input field', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: {},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow('workflow-id', 'NVDA')
|
|
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
|
|
|
expect(requestBody).toHaveProperty('input', 'NVDA')
|
|
expect(requestBody).not.toHaveProperty('0') // Should not spread string characters
|
|
})
|
|
|
|
it('should wrap primitive number input in input field', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: {},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow('workflow-id', 42)
|
|
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
|
|
|
expect(requestBody).toHaveProperty('input', 42)
|
|
})
|
|
|
|
it('should wrap array input in input field', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: {},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow('workflow-id', ['NVDA', 'AAPL', 'GOOG'])
|
|
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
|
|
|
expect(requestBody).toHaveProperty('input')
|
|
expect(requestBody.input).toEqual(['NVDA', 'AAPL', 'GOOG'])
|
|
expect(requestBody).not.toHaveProperty('0') // Should not spread array
|
|
})
|
|
|
|
it('should spread object input at root level', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: {},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow('workflow-id', { ticker: 'NVDA', quantity: 100 })
|
|
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
|
|
|
expect(requestBody).toHaveProperty('ticker', 'NVDA')
|
|
expect(requestBody).toHaveProperty('quantity', 100)
|
|
expect(requestBody).not.toHaveProperty('input') // Should not wrap in input field
|
|
})
|
|
|
|
it('should handle null input as no input (empty body)', async () => {
|
|
const fetch = await import('node-fetch')
|
|
const mockResponse = {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
output: {},
|
|
}),
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
}
|
|
|
|
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
|
|
|
|
await client.executeWorkflow('workflow-id', null)
|
|
|
|
const calls = vi.mocked(fetch.default).mock.calls
|
|
const requestBody = JSON.parse(calls[0][1]?.body as string)
|
|
|
|
// null treated as "no input" - sends empty body (consistent with Python SDK)
|
|
expect(requestBody).toEqual({})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('SimStudioError', () => {
|
|
it('should create error with message', () => {
|
|
const error = new SimStudioError('Test error')
|
|
expect(error.message).toBe('Test error')
|
|
expect(error.name).toBe('SimStudioError')
|
|
})
|
|
|
|
it('should create error with code and status', () => {
|
|
const error = new SimStudioError('Test error', 'TEST_CODE', 400)
|
|
expect(error.message).toBe('Test error')
|
|
expect(error.code).toBe('TEST_CODE')
|
|
expect(error.status).toBe(400)
|
|
})
|
|
})
|