diff --git a/apps/sim/app/api/__test-utils__/utils.ts b/apps/sim/app/api/__test-utils__/utils.ts deleted file mode 100644 index 3ecefb443c..0000000000 --- a/apps/sim/app/api/__test-utils__/utils.ts +++ /dev/null @@ -1,1565 +0,0 @@ -import { createMockLogger as createSimTestingMockLogger } from '@sim/testing' -import { NextRequest } from 'next/server' -import { vi } from 'vitest' - -export { createMockLogger } from '@sim/testing' - -export interface MockUser { - id: string - email: string - name?: string -} - -export interface MockAuthResult { - mockGetSession: ReturnType - mockAuthenticatedUser: (user?: MockUser) => void - mockUnauthenticated: () => void - setAuthenticated: (user?: MockUser) => void - setUnauthenticated: () => void -} - -export interface DatabaseSelectResult { - id: string - [key: string]: any -} - -export interface DatabaseInsertResult { - id: string - [key: string]: any -} - -export interface DatabaseUpdateResult { - id: string - updatedAt?: Date - [key: string]: any -} - -export interface DatabaseDeleteResult { - id: string - [key: string]: any -} - -export interface MockDatabaseOptions { - select?: { - results?: any[][] - throwError?: boolean - errorMessage?: string - } - insert?: { - results?: any[] - throwError?: boolean - errorMessage?: string - } - update?: { - results?: any[] - throwError?: boolean - errorMessage?: string - } - delete?: { - results?: any[] - throwError?: boolean - errorMessage?: string - } - transaction?: { - throwError?: boolean - errorMessage?: string - } -} - -export interface CapturedFolderValues { - name?: string - color?: string - parentId?: string | null - isExpanded?: boolean - sortOrder?: number - updatedAt?: Date -} - -export interface CapturedWorkflowValues { - name?: string - description?: string - color?: string - folderId?: string | null - state?: any - updatedAt?: Date -} - -export const sampleWorkflowState = { - blocks: { - 'starter-id': { - id: 'starter-id', - type: 'starter', - name: 'Start', - position: { x: 100, y: 100 }, - subBlocks: { - startWorkflow: { id: 'startWorkflow', type: 'dropdown', value: 'manual' }, - webhookPath: { id: 'webhookPath', type: 'short-input', value: '' }, - }, - outputs: { - input: 'any', - }, - enabled: true, - horizontalHandles: true, - advancedMode: false, - triggerMode: false, - height: 95, - }, - 'agent-id': { - id: 'agent-id', - type: 'agent', - name: 'Agent 1', - position: { x: 634, y: -167 }, - subBlocks: { - systemPrompt: { - id: 'systemPrompt', - type: 'long-input', - value: 'You are a helpful assistant', - }, - context: { id: 'context', type: 'short-input', value: '' }, - model: { id: 'model', type: 'dropdown', value: 'gpt-4o' }, - apiKey: { id: 'apiKey', type: 'short-input', value: '{{OPENAI_API_KEY}}' }, - }, - outputs: { - response: { - content: 'string', - model: 'string', - tokens: 'any', - }, - }, - enabled: true, - horizontalHandles: true, - advancedMode: false, - triggerMode: false, - height: 680, - }, - }, - edges: [ - { - id: 'edge-id', - source: 'starter-id', - target: 'agent-id', - sourceHandle: 'source', - targetHandle: 'target', - }, - ], - loops: {}, - parallels: {}, - lastSaved: Date.now(), - isDeployed: false, -} - -// Global mock data that can be configured by tests -export const globalMockData = { - webhooks: [] as any[], - workflows: [] as any[], - schedules: [] as any[], - shouldThrowError: false, - errorMessage: 'Database error', -} - -export const mockDb = { - select: vi.fn().mockImplementation(() => { - if (globalMockData.shouldThrowError) { - throw new Error(globalMockData.errorMessage) - } - return { - from: vi.fn().mockImplementation(() => ({ - innerJoin: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => { - // Return webhook/workflow join data if available - if (globalMockData.webhooks.length > 0) { - return [ - { - webhook: globalMockData.webhooks[0], - workflow: globalMockData.workflows[0] || { - id: 'test-workflow', - userId: 'test-user', - }, - }, - ] - } - return [] - }), - })), - })), - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => { - // Return schedules if available - if (globalMockData.schedules.length > 0) { - return globalMockData.schedules - } - // Return simple workflow data - if (globalMockData.workflows.length > 0) { - return globalMockData.workflows - } - return [ - { - id: 'workflow-id', - userId: 'user-id', - state: sampleWorkflowState, - }, - ] - }), - })), - })), - } - }), - update: vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - })), - eq: vi.fn().mockImplementation((field, value) => ({ field, value, type: 'eq' })), - and: vi.fn().mockImplementation((...conditions) => ({ - conditions, - type: 'and', - })), -} - -/** - * Mock logger using @sim/testing createMockLogger. - * This provides a consistent mock logger across all API tests. - */ -export const mockLogger = createSimTestingMockLogger() - -export const mockUser = { - id: 'user-123', - email: 'test@example.com', -} - -export const mockSubscription = { - id: 'sub-123', - plan: 'enterprise', - status: 'active', - seats: 5, - referenceId: 'user-123', - metadata: { - perSeatAllowance: 100, - totalAllowance: 500, - updatedAt: '2023-01-01T00:00:00.000Z', - }, -} - -export const mockOrganization = { - id: 'org-456', - name: 'Test Organization', - slug: 'test-org', -} - -export const mockAdminMember = { - id: 'member-123', - userId: 'user-123', - organizationId: 'org-456', - role: 'admin', -} - -export const mockRegularMember = { - id: 'member-456', - userId: 'user-123', - organizationId: 'org-456', - role: 'member', -} - -export const mockTeamSubscription = { - id: 'sub-456', - plan: 'team', - status: 'active', - seats: 5, - referenceId: 'org-123', -} - -export const mockPersonalSubscription = { - id: 'sub-789', - plan: 'enterprise', - status: 'active', - seats: 5, - referenceId: 'user-123', - metadata: { - perSeatAllowance: 100, - totalAllowance: 500, - updatedAt: '2023-01-01T00:00:00.000Z', - }, -} - -export const mockEnvironmentVars = { - OPENAI_API_KEY: 'encrypted:openai-api-key', - SERPER_API_KEY: 'encrypted:serper-api-key', -} - -export const mockDecryptedEnvVars = { - OPENAI_API_KEY: 'sk-test123', - SERPER_API_KEY: 'serper-test123', -} - -export function createMockRequest( - method = 'GET', - body?: any, - headers: Record = {} -): NextRequest { - const url = 'http://localhost:3000/api/test' - - return new NextRequest(new URL(url), { - method, - headers: new Headers(headers), - body: body ? JSON.stringify(body) : undefined, - }) -} - -export function mockExecutionDependencies() { - vi.mock('@/lib/core/security/encryption', () => ({ - decryptSecret: vi.fn().mockImplementation((encrypted: string) => { - const entries = Object.entries(mockEnvironmentVars) - const found = entries.find(([_, val]) => val === encrypted) - const key = found ? found[0] : null - - return Promise.resolve({ - decrypted: - key && key in mockDecryptedEnvVars - ? mockDecryptedEnvVars[key as keyof typeof mockDecryptedEnvVars] - : 'decrypted-value', - }) - }), - })) - - vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({ - buildTraceSpans: vi.fn().mockReturnValue({ - traceSpans: [], - totalDuration: 100, - }), - })) - - vi.mock('@/lib/workflows/utils', () => ({ - updateWorkflowRunCounts: vi.fn().mockResolvedValue(undefined), - })) - - vi.mock('@/serializer', () => ({ - Serializer: vi.fn().mockImplementation(() => ({ - serializeWorkflow: vi.fn().mockReturnValue({ - version: '1.0', - blocks: [ - { - id: 'starter-id', - metadata: { id: 'starter', name: 'Start' }, - config: {}, - inputs: {}, - outputs: {}, - position: { x: 100, y: 100 }, - enabled: true, - }, - { - id: 'agent-id', - metadata: { id: 'agent', name: 'Agent 1' }, - config: {}, - inputs: {}, - outputs: {}, - position: { x: 634, y: -167 }, - enabled: true, - }, - ], - connections: [ - { - source: 'starter-id', - target: 'agent-id', - }, - ], - loops: {}, - }), - })), - })) - - vi.mock('@/executor', () => ({ - Executor: vi.fn().mockImplementation(() => ({ - execute: vi.fn().mockResolvedValue({ - success: true, - output: { - response: { - content: 'This is a test response', - model: 'gpt-4o', - }, - }, - logs: [], - metadata: { - duration: 1000, - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - }, - }), - })), - })) - - vi.mock('@sim/db', () => ({ - db: mockDb, - // Add common schema exports that tests might need - webhook: { - id: 'id', - path: 'path', - workflowId: 'workflowId', - isActive: 'isActive', - provider: 'provider', - providerConfig: 'providerConfig', - }, - workflow: { - id: 'id', - userId: 'userId', - }, - workflowSchedule: { - id: 'id', - workflowId: 'workflowId', - nextRunAt: 'nextRunAt', - status: 'status', - }, - userStats: { - userId: 'userId', - totalScheduledExecutions: 'totalScheduledExecutions', - lastActive: 'lastActive', - }, - })) -} - -/** - * Mock Trigger.dev SDK (tasks.trigger and task factory) for tests that import background modules - */ -export function mockTriggerDevSdk() { - vi.mock('@trigger.dev/sdk', () => ({ - tasks: { - trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), - }, - task: vi.fn().mockReturnValue({}), - })) -} - -export function mockWorkflowAccessValidation(shouldSucceed = true) { - if (shouldSucceed) { - vi.mock('@/app/api/workflows/middleware', () => ({ - validateWorkflowAccess: vi.fn().mockResolvedValue({ - workflow: { - id: 'workflow-id', - userId: 'user-id', - state: sampleWorkflowState, - }, - }), - })) - } else { - vi.mock('@/app/api/workflows/middleware', () => ({ - validateWorkflowAccess: vi.fn().mockResolvedValue({ - error: { - message: 'Access denied', - status: 403, - }, - }), - })) - } -} - -export async function getMockedDependencies() { - const encryptionModule = await import('@/lib/core/security/encryption') - const traceSpansModule = await import('@/lib/logs/execution/trace-spans/trace-spans') - const workflowUtilsModule = await import('@/lib/workflows/utils') - const executorModule = await import('@/executor') - const serializerModule = await import('@/serializer') - const dbModule = await import('@sim/db') - - return { - decryptSecret: encryptionModule.decryptSecret, - buildTraceSpans: traceSpansModule.buildTraceSpans, - updateWorkflowRunCounts: workflowUtilsModule.updateWorkflowRunCounts, - Executor: executorModule.Executor, - Serializer: serializerModule.Serializer, - db: dbModule.db, - } -} - -export function mockScheduleStatusDb({ - schedule = [ - { - id: 'schedule-id', - workflowId: 'workflow-id', - status: 'active', - failedCount: 0, - lastRanAt: new Date('2024-01-01T00:00:00.000Z'), - lastFailedAt: null, - nextRunAt: new Date('2024-01-02T00:00:00.000Z'), - }, - ], - workflow = [ - { - userId: 'user-id', - }, - ], -}: { - schedule?: any[] - workflow?: any[] -} = {}) { - vi.doMock('@sim/db', () => { - let callCount = 0 - - const select = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => { - callCount += 1 - if (callCount === 1) return schedule - if (callCount === 2) return workflow - return [] - }), - })), - })), - })) - - return { - db: { select }, - } - }) -} - -export function mockScheduleExecuteDb({ - schedules = [] as any[], - workflowRecord = { - id: 'workflow-id', - userId: 'user-id', - state: sampleWorkflowState, - }, - envRecord = { - userId: 'user-id', - variables: { - OPENAI_API_KEY: 'encrypted:openai-api-key', - SERPER_API_KEY: 'encrypted:serper-api-key', - }, - }, -}: { - schedules?: any[] - workflowRecord?: any - envRecord?: any -}): void { - vi.doMock('@sim/db', () => { - const select = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation((table: any) => { - const tbl = String(table) - if (tbl === 'workflow_schedule' || tbl === 'schedule') { - return { - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => schedules), - })), - } - } - - if (tbl === 'workflow') { - return { - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => [workflowRecord]), - })), - } - } - - if (tbl === 'environment') { - return { - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => [envRecord]), - })), - } - } - - return { - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => []), - })), - } - }), - })) - - const update = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - })) - - return { db: { select, update } } - }) -} - -/** - * Mock authentication for API tests - * @param user - Optional user object to use for authenticated requests - * @returns Object with authentication helper functions - */ -export function mockAuth(user: MockUser = mockUser): MockAuthResult { - const mockGetSession = vi.fn() - - vi.doMock('@/lib/auth', () => ({ - getSession: mockGetSession, - })) - - const setAuthenticated = (customUser?: MockUser) => - mockGetSession.mockResolvedValue({ user: customUser || user }) - const setUnauthenticated = () => mockGetSession.mockResolvedValue(null) - - return { - mockGetSession, - mockAuthenticatedUser: setAuthenticated, - mockUnauthenticated: setUnauthenticated, - setAuthenticated, - setUnauthenticated, - } -} - -/** - * Mock common schema patterns - */ -export function mockCommonSchemas() { - vi.doMock('@sim/db/schema', () => ({ - workflowFolder: { - id: 'id', - userId: 'userId', - parentId: 'parentId', - updatedAt: 'updatedAt', - workspaceId: 'workspaceId', - sortOrder: 'sortOrder', - createdAt: 'createdAt', - }, - workflow: { - id: 'id', - folderId: 'folderId', - userId: 'userId', - updatedAt: 'updatedAt', - }, - account: { - userId: 'userId', - providerId: 'providerId', - }, - user: { - email: 'email', - id: 'id', - }, - })) -} - -/** - * Mock drizzle-orm operators - */ -export function mockDrizzleOrm() { - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - or: vi.fn((...conditions) => ({ type: 'or', conditions })), - gte: vi.fn((field, value) => ({ type: 'gte', field, value })), - lte: vi.fn((field, value) => ({ type: 'lte', field, value })), - asc: vi.fn((field) => ({ field, type: 'asc' })), - desc: vi.fn((field) => ({ field, type: 'desc' })), - isNull: vi.fn((field) => ({ field, type: 'isNull' })), - count: vi.fn((field) => ({ field, type: 'count' })), - sql: vi.fn((strings, ...values) => ({ - type: 'sql', - sql: strings, - values, - })), - })) -} - -/** - * Mock knowledge-related database schemas - */ -export function mockKnowledgeSchemas() { - vi.doMock('@sim/db/schema', () => ({ - knowledgeBase: { - id: 'kb_id', - userId: 'user_id', - name: 'kb_name', - description: 'description', - tokenCount: 'token_count', - embeddingModel: 'embedding_model', - embeddingDimension: 'embedding_dimension', - chunkingConfig: 'chunking_config', - workspaceId: 'workspace_id', - createdAt: 'created_at', - updatedAt: 'updated_at', - deletedAt: 'deleted_at', - }, - document: { - id: 'doc_id', - knowledgeBaseId: 'kb_id', - filename: 'filename', - fileUrl: 'file_url', - fileSize: 'file_size', - mimeType: 'mime_type', - chunkCount: 'chunk_count', - tokenCount: 'token_count', - characterCount: 'character_count', - processingStatus: 'processing_status', - processingStartedAt: 'processing_started_at', - processingCompletedAt: 'processing_completed_at', - processingError: 'processing_error', - enabled: 'enabled', - tag1: 'tag1', - tag2: 'tag2', - tag3: 'tag3', - tag4: 'tag4', - tag5: 'tag5', - tag6: 'tag6', - tag7: 'tag7', - uploadedAt: 'uploaded_at', - deletedAt: 'deleted_at', - }, - embedding: { - id: 'embedding_id', - documentId: 'doc_id', - knowledgeBaseId: 'kb_id', - chunkIndex: 'chunk_index', - content: 'content', - embedding: 'embedding', - tokenCount: 'token_count', - characterCount: 'character_count', - tag1: 'tag1', - tag2: 'tag2', - tag3: 'tag3', - tag4: 'tag4', - tag5: 'tag5', - tag6: 'tag6', - tag7: 'tag7', - createdAt: 'created_at', - }, - permissions: { - id: 'permission_id', - userId: 'user_id', - entityType: 'entity_type', - entityId: 'entity_id', - permissionType: 'permission_type', - createdAt: 'created_at', - updatedAt: 'updated_at', - }, - })) -} - -/** - * Mock console logger using the shared mockLogger instance. - * This ensures tests can assert on the same mockLogger instance exported from this module. - */ -export function mockConsoleLogger() { - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue(mockLogger), - })) -} - -/** - * Setup common API test mocks (auth, logger, schema, drizzle) - */ -export function setupCommonApiMocks() { - mockCommonSchemas() - mockDrizzleOrm() - mockConsoleLogger() -} - -/** - * Mock UUID generation for consistent test results - */ -export function mockUuid(mockValue = 'test-uuid') { - vi.doMock('uuid', () => ({ - v4: vi.fn().mockReturnValue(mockValue), - })) -} - -/** - * Mock crypto.randomUUID for tests - */ -export function mockCryptoUuid(mockValue = 'mock-uuid-1234-5678') { - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue(mockValue), - }) -} - -/** - * Mock file system operations - */ -export function mockFileSystem( - options: { writeFileSuccess?: boolean; readFileContent?: string; existsResult?: boolean } = {} -) { - const { writeFileSuccess = true, readFileContent = 'test content', existsResult = true } = options - - vi.doMock('fs/promises', () => ({ - writeFile: vi.fn().mockImplementation(() => { - if (writeFileSuccess) { - return Promise.resolve() - } - return Promise.reject(new Error('Write failed')) - }), - readFile: vi.fn().mockResolvedValue(readFileContent), - stat: vi.fn().mockResolvedValue({ size: 100, isFile: () => true }), - access: vi.fn().mockImplementation(() => { - if (existsResult) { - return Promise.resolve() - } - return Promise.reject(new Error('File not found')) - }), - mkdir: vi.fn().mockResolvedValue(undefined), - })) -} - -/** - * Mock encryption utilities - */ -export function mockEncryption(options: { encryptedValue?: string; decryptedValue?: string } = {}) { - const { encryptedValue = 'encrypted-value', decryptedValue = 'decrypted-value' } = options - - vi.doMock('@/lib/core/security/encryption', () => ({ - encryptSecret: vi.fn().mockResolvedValue({ encrypted: encryptedValue }), - decryptSecret: vi.fn().mockResolvedValue({ decrypted: decryptedValue }), - })) -} - -/** - * Interface for storage provider mock configuration - */ -export interface StorageProviderMockOptions { - provider?: 's3' | 'blob' | 'local' - isCloudEnabled?: boolean - throwError?: boolean - errorMessage?: string - presignedUrl?: string - uploadHeaders?: Record -} - -/** - * Create storage provider mocks (S3, Blob, Local) - */ -export function createStorageProviderMocks(options: StorageProviderMockOptions = {}) { - const { - provider = 's3', - isCloudEnabled = true, - throwError = false, - errorMessage = 'Storage error', - presignedUrl = 'https://example.com/presigned-url', - uploadHeaders = {}, - } = options - - mockUuid('mock-uuid-1234') - mockCryptoUuid('mock-uuid-1234-5678') - - const uploadFileMock = vi.fn().mockResolvedValue({ - path: '/api/files/serve/test-key.txt', - key: 'test-key.txt', - name: 'test.txt', - size: 100, - type: 'text/plain', - }) - const downloadFileMock = vi.fn().mockResolvedValue(Buffer.from('test content')) - const deleteFileMock = vi.fn().mockResolvedValue(undefined) - const hasCloudStorageMock = vi.fn().mockReturnValue(isCloudEnabled) - - const generatePresignedUploadUrlMock = vi.fn().mockImplementation((params: any) => { - const { fileName, context } = params - const timestamp = Date.now() - const random = Math.random().toString(36).substring(2, 9) - - let key = '' - if (context === 'knowledge-base') { - key = `kb/${timestamp}-${random}-${fileName}` - } else if (context === 'chat') { - key = `chat/${timestamp}-${random}-${fileName}` - } else if (context === 'copilot') { - key = `copilot/${timestamp}-${random}-${fileName}` - } else if (context === 'workspace') { - key = `workspace/${timestamp}-${random}-${fileName}` - } else { - key = `${timestamp}-${random}-${fileName}` - } - - return Promise.resolve({ - url: presignedUrl, - key, - uploadHeaders: uploadHeaders, - }) - }) - - const generatePresignedDownloadUrlMock = vi.fn().mockResolvedValue(presignedUrl) - - vi.doMock('@/lib/uploads', () => ({ - getStorageProvider: vi.fn().mockReturnValue(provider), - isUsingCloudStorage: vi.fn().mockReturnValue(isCloudEnabled), - StorageService: { - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - hasCloudStorage: hasCloudStorageMock, - generatePresignedUploadUrl: generatePresignedUploadUrlMock, - generatePresignedDownloadUrl: generatePresignedDownloadUrlMock, - }, - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - getPresignedUrl: vi.fn().mockResolvedValue(presignedUrl), - hasCloudStorage: hasCloudStorageMock, - generatePresignedDownloadUrl: generatePresignedDownloadUrlMock, - })) - - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - hasCloudStorage: hasCloudStorageMock, - generatePresignedUploadUrl: generatePresignedUploadUrlMock, - generatePresignedDownloadUrl: generatePresignedDownloadUrlMock, - StorageService: { - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - hasCloudStorage: hasCloudStorageMock, - generatePresignedUploadUrl: generatePresignedUploadUrlMock, - generatePresignedDownloadUrl: generatePresignedDownloadUrlMock, - }, - })) - - vi.doMock('@/lib/uploads/config', () => ({ - USE_S3_STORAGE: provider === 's3', - USE_BLOB_STORAGE: provider === 'blob', - USE_LOCAL_STORAGE: provider === 'local', - getStorageProvider: vi.fn().mockReturnValue(provider), - S3_CONFIG: { - bucket: 'test-s3-bucket', - region: 'us-east-1', - }, - S3_KB_CONFIG: { - bucket: 'test-s3-kb-bucket', - region: 'us-east-1', - }, - S3_CHAT_CONFIG: { - bucket: 'test-s3-chat-bucket', - region: 'us-east-1', - }, - BLOB_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-container', - }, - BLOB_KB_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-kb-container', - }, - BLOB_CHAT_CONFIG: { - accountName: 'testaccount', - accountKey: 'testkey', - containerName: 'test-chat-container', - }, - })) - - if (provider === 's3') { - vi.doMock('@/lib/uploads/providers/s3/client', () => ({ - getS3Client: vi.fn().mockReturnValue({}), - })) - vi.doMock('@aws-sdk/client-s3', () => ({ - PutObjectCommand: vi.fn(), - })) - - vi.doMock('@aws-sdk/s3-request-presigner', () => ({ - getSignedUrl: vi.fn().mockImplementation(() => { - if (throwError) { - return Promise.reject(new Error(errorMessage)) - } - return Promise.resolve(presignedUrl) - }), - })) - } else if (provider === 'blob') { - const baseUrl = 'https://testaccount.blob.core.windows.net/test-container' - const mockBlockBlobClient = { - url: baseUrl, - } - const mockContainerClient = { - getBlockBlobClient: vi.fn(() => mockBlockBlobClient), - } - const mockBlobServiceClient = { - getContainerClient: vi.fn(() => { - if (throwError) { - throw new Error(errorMessage) - } - return mockContainerClient - }), - } - - vi.doMock('@/lib/uploads/providers/blob/client', () => ({ - getBlobServiceClient: vi.fn().mockReturnValue(mockBlobServiceClient), - })) - vi.doMock('@azure/storage-blob', () => ({ - BlobSASPermissions: { - parse: vi.fn(() => 'w'), - }, - generateBlobSASQueryParameters: vi.fn(() => ({ - toString: () => 'sas-token-string', - })), - StorageSharedKeyCredential: vi.fn(), - })) - } - - return { - provider, - isCloudEnabled, - mockBlobClient: provider === 'blob' ? vi.fn() : undefined, - mockS3Client: provider === 's3' ? vi.fn() : undefined, - } -} - -/** - * Interface for auth API mock configuration with all auth operations - */ -export interface AuthApiMockOptions { - operations?: { - forgetPassword?: { - success?: boolean - error?: string - } - resetPassword?: { - success?: boolean - error?: string - } - signIn?: { - success?: boolean - error?: string - } - signUp?: { - success?: boolean - error?: string - } - } -} - -/** - * Interface for comprehensive test setup options - */ -export interface TestSetupOptions { - auth?: { - authenticated?: boolean - user?: MockUser - } - database?: MockDatabaseOptions - storage?: StorageProviderMockOptions - authApi?: AuthApiMockOptions - features?: { - workflowUtils?: boolean - fileSystem?: boolean - uploadUtils?: boolean - encryption?: boolean - } -} - -/** - * Master setup function for comprehensive test mocking - * This is the preferred setup function for new tests - */ -export function setupComprehensiveTestMocks(options: TestSetupOptions = {}) { - const { auth = { authenticated: true }, database = {}, storage, authApi, features = {} } = options - - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - - const authMocks = mockAuth(auth.user) - if (auth.authenticated) { - authMocks.setAuthenticated(auth.user) - } else { - authMocks.setUnauthenticated() - } - - const dbMocks = createMockDatabase(database) - - let storageMocks - if (storage) { - storageMocks = createStorageProviderMocks(storage) - } - - let authApiMocks - if (authApi) { - authApiMocks = createAuthApiMocks(authApi) - } - - const featureMocks: any = {} - if (features.workflowUtils) { - featureMocks.workflowUtils = mockWorkflowUtils() - } - if (features.fileSystem) { - featureMocks.fileSystem = mockFileSystem() - } - if (features.uploadUtils) { - featureMocks.uploadUtils = mockUploadUtils() - } - if (features.encryption) { - featureMocks.encryption = mockEncryption() - } - - return { - auth: authMocks, - database: dbMocks, - storage: storageMocks, - authApi: authApiMocks, - features: featureMocks, - } -} - -/** - * Create a more focused and composable database mock - */ -export function createMockDatabase(options: MockDatabaseOptions = {}) { - const selectOptions = options.select || { results: [[]], throwError: false } - const insertOptions = options.insert || { results: [{ id: 'mock-id' }], throwError: false } - const updateOptions = options.update || { results: [{ id: 'mock-id' }], throwError: false } - const deleteOptions = options.delete || { results: [{ id: 'mock-id' }], throwError: false } - const transactionOptions = options.transaction || { throwError: false } - - let selectCallCount = 0 - - const createDbError = (operation: string, message?: string) => { - return new Error(message || `Database ${operation} error`) - } - - const createSelectChain = () => ({ - from: vi.fn().mockReturnThis(), - leftJoin: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - groupBy: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockImplementation(() => { - if (selectOptions.throwError) { - return Promise.reject(createDbError('select', selectOptions.errorMessage)) - } - const result = selectOptions.results?.[selectCallCount] || selectOptions.results?.[0] || [] - selectCallCount++ - return Promise.resolve(result) - }), - limit: vi.fn().mockImplementation(() => { - if (selectOptions.throwError) { - return Promise.reject(createDbError('select', selectOptions.errorMessage)) - } - const result = selectOptions.results?.[selectCallCount] || selectOptions.results?.[0] || [] - selectCallCount++ - return Promise.resolve(result) - }), - }) - - const createInsertChain = () => ({ - values: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockImplementation(() => { - if (insertOptions.throwError) { - return Promise.reject(createDbError('insert', insertOptions.errorMessage)) - } - return Promise.resolve(insertOptions.results) - }), - onConflictDoUpdate: vi.fn().mockImplementation(() => { - if (insertOptions.throwError) { - return Promise.reject(createDbError('insert', insertOptions.errorMessage)) - } - return Promise.resolve(insertOptions.results) - }), - })), - }) - - const createUpdateChain = () => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockImplementation(() => { - if (updateOptions.throwError) { - return Promise.reject(createDbError('update', updateOptions.errorMessage)) - } - return Promise.resolve(updateOptions.results) - }), - then: vi.fn().mockImplementation((resolve) => { - if (updateOptions.throwError) { - return Promise.reject(createDbError('update', updateOptions.errorMessage)) - } - return Promise.resolve(updateOptions.results).then(resolve) - }), - })), - })), - }) - - const createDeleteChain = () => ({ - where: vi.fn().mockImplementation(() => { - if (deleteOptions.throwError) { - return Promise.reject(createDbError('delete', deleteOptions.errorMessage)) - } - return Promise.resolve(deleteOptions.results) - }), - }) - - const createTransactionMock = () => { - return vi.fn().mockImplementation(async (callback: any) => { - if (transactionOptions.throwError) { - throw createDbError('transaction', transactionOptions.errorMessage) - } - - const tx = { - select: vi.fn().mockImplementation(() => createSelectChain()), - insert: vi.fn().mockImplementation(() => createInsertChain()), - update: vi.fn().mockImplementation(() => createUpdateChain()), - delete: vi.fn().mockImplementation(() => createDeleteChain()), - } - return await callback(tx) - }) - } - - const mockDb = { - select: vi.fn().mockImplementation(() => createSelectChain()), - insert: vi.fn().mockImplementation(() => createInsertChain()), - update: vi.fn().mockImplementation(() => createUpdateChain()), - delete: vi.fn().mockImplementation(() => createDeleteChain()), - transaction: createTransactionMock(), - } - - vi.doMock('@sim/db', () => ({ db: mockDb })) - - return { - mockDb, - resetSelectCallCount: () => { - selectCallCount = 0 - }, - } -} - -/** - * Create comprehensive auth API mocks - */ -export function createAuthApiMocks(options: AuthApiMockOptions = {}) { - const { operations = {} } = options - - const defaultOperations = { - forgetPassword: { success: true, error: 'Forget password error' }, - resetPassword: { success: true, error: 'Reset password error' }, - signIn: { success: true, error: 'Sign in error' }, - signUp: { success: true, error: 'Sign up error' }, - ...operations, - } - - const createAuthMethod = (operation: string, config: { success?: boolean; error?: string }) => { - return vi.fn().mockImplementation(() => { - if (config.success) { - return Promise.resolve() - } - return Promise.reject(new Error(config.error)) - }) - } - - vi.doMock('@/lib/auth', () => ({ - auth: { - api: { - forgetPassword: createAuthMethod('forgetPassword', defaultOperations.forgetPassword), - resetPassword: createAuthMethod('resetPassword', defaultOperations.resetPassword), - signIn: createAuthMethod('signIn', defaultOperations.signIn), - signUp: createAuthMethod('signUp', defaultOperations.signUp), - }, - }, - })) - - return { - operations: defaultOperations, - } -} - -/** - * Mock workflow utilities and response helpers - */ -export function mockWorkflowUtils() { - vi.doMock('@/app/api/workflows/utils', () => ({ - createSuccessResponse: vi.fn().mockImplementation((data) => { - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - }), - createErrorResponse: vi.fn().mockImplementation((message, status = 500) => { - return new Response(JSON.stringify({ error: message }), { - status, - headers: { 'Content-Type': 'application/json' }, - }) - }), - })) -} - -/** - * Setup grouped mocks for knowledge base operations - */ -export function setupKnowledgeMocks( - options: { - withDocumentProcessing?: boolean - withEmbedding?: boolean - accessCheckResult?: boolean - } = {} -) { - const { - withDocumentProcessing = false, - withEmbedding = false, - accessCheckResult = true, - } = options - - const mocks: any = { - checkKnowledgeBaseAccess: vi.fn().mockResolvedValue(accessCheckResult), - } - - if (withDocumentProcessing) { - mocks.processDocumentAsync = vi.fn().mockResolvedValue(undefined) - } - - if (withEmbedding) { - mocks.generateEmbedding = vi.fn().mockResolvedValue([0.1, 0.2, 0.3]) - } - - vi.doMock('@/app/api/knowledge/utils', () => mocks) - - return mocks -} - -/** - * Setup for file-related API routes - */ -export function setupFileApiMocks( - options: { - authenticated?: boolean - storageProvider?: 's3' | 'blob' | 'local' - cloudEnabled?: boolean - } = {} -) { - const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - - const authMocks = mockAuth() - if (authenticated) { - authMocks.setAuthenticated() - } else { - authMocks.setUnauthenticated() - } - - vi.doMock('@/lib/auth/hybrid', () => ({ - checkHybridAuth: vi.fn().mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }), - })) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), - verifyKBFileAccess: vi.fn().mockResolvedValue(true), - verifyCopilotFileAccess: vi.fn().mockResolvedValue(true), - lookupWorkspaceFileByKey: vi.fn().mockResolvedValue({ - workspaceId: 'test-workspace-id', - uploadedBy: 'test-user-id', - }), - })) - - vi.doMock('@/lib/uploads/contexts/workspace', () => ({ - uploadWorkspaceFile: vi.fn().mockResolvedValue({ - id: 'test-file-id', - name: 'test.txt', - url: '/api/files/serve/workspace/test-workspace-id/test-file.txt', - size: 100, - type: 'text/plain', - key: 'workspace/test-workspace-id/1234567890-test.txt', - uploadedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - }), - })) - - mockFileSystem({ - writeFileSuccess: true, - readFileContent: 'test content', - existsResult: true, - }) - - let storageMocks - if (storageProvider) { - storageMocks = createStorageProviderMocks({ - provider: storageProvider, - isCloudEnabled: cloudEnabled, - }) - } else { - const uploadFileMock = vi.fn().mockResolvedValue({ - path: '/api/files/serve/test-key.txt', - key: 'test-key.txt', - name: 'test.txt', - size: 100, - type: 'text/plain', - }) - const downloadFileMock = vi.fn().mockResolvedValue(Buffer.from('test content')) - const deleteFileMock = vi.fn().mockResolvedValue(undefined) - const hasCloudStorageMock = vi.fn().mockReturnValue(cloudEnabled) - - vi.doMock('@/lib/uploads', () => ({ - getStorageProvider: vi.fn().mockReturnValue('local'), - isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), - StorageService: { - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - hasCloudStorage: hasCloudStorageMock, - generatePresignedUploadUrl: vi.fn().mockResolvedValue({ - presignedUrl: 'https://example.com/presigned-url', - key: 'test-key.txt', - }), - generatePresignedDownloadUrl: vi - .fn() - .mockResolvedValue('https://example.com/presigned-url'), - }, - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - getPresignedUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'), - hasCloudStorage: hasCloudStorageMock, - })) - } - - return { - auth: authMocks, - storage: storageMocks, - } -} - -/** - * Setup for auth-related API routes - */ -export function setupAuthApiMocks(options: { operations?: AuthApiMockOptions['operations'] } = {}) { - return setupComprehensiveTestMocks({ - auth: { authenticated: false }, // Auth routes typically don't require authentication - authApi: { operations: options.operations }, - }) -} - -/** - * Setup for knowledge base API routes - */ -export function setupKnowledgeApiMocks( - options: { - authenticated?: boolean - withDocumentProcessing?: boolean - withEmbedding?: boolean - } = {} -) { - const mocks = setupComprehensiveTestMocks({ - auth: { authenticated: options.authenticated ?? true }, - database: { - select: { results: [[]] }, - }, - }) - - const knowledgeMocks = setupKnowledgeMocks({ - withDocumentProcessing: options.withDocumentProcessing, - withEmbedding: options.withEmbedding, - }) - - return { - ...mocks, - knowledge: knowledgeMocks, - } -} - -export function setupApiTestMocks( - options: { - authenticated?: boolean - user?: MockUser - dbResults?: any[][] - withWorkflowUtils?: boolean - withFileSystem?: boolean - withUploadUtils?: boolean - } = {} -) { - const { - authenticated = true, - user = mockUser, - dbResults = [[]], - withWorkflowUtils = false, - withFileSystem = false, - withUploadUtils = false, - } = options - - return setupComprehensiveTestMocks({ - auth: { authenticated, user }, - database: { select: { results: dbResults } }, - features: { - workflowUtils: withWorkflowUtils, - fileSystem: withFileSystem, - uploadUtils: withUploadUtils, - }, - }) -} - -export function mockUploadUtils( - options: { isCloudStorage?: boolean; uploadResult?: any; uploadError?: boolean } = {} -) { - const { - isCloudStorage = false, - uploadResult = { - path: '/api/files/serve/test-key.txt', - key: 'test-key.txt', - name: 'test.txt', - size: 100, - type: 'text/plain', - }, - uploadError = false, - } = options - - const uploadFileMock = vi.fn().mockImplementation(() => { - if (uploadError) { - return Promise.reject(new Error('Upload failed')) - } - return Promise.resolve(uploadResult) - }) - - vi.doMock('@/lib/uploads', () => ({ - StorageService: { - uploadFile: uploadFileMock, - downloadFile: vi.fn().mockResolvedValue(Buffer.from('test content')), - deleteFile: vi.fn().mockResolvedValue(undefined), - hasCloudStorage: vi.fn().mockReturnValue(isCloudStorage), - }, - uploadFile: uploadFileMock, - isUsingCloudStorage: vi.fn().mockReturnValue(isCloudStorage), - })) - - vi.doMock('@/lib/uploads/config', () => ({ - UPLOAD_DIR: '/test/uploads', - USE_S3_STORAGE: isCloudStorage, - USE_BLOB_STORAGE: false, - S3_CONFIG: { - bucket: 'test-bucket', - region: 'test-region', - }, - })) -} - -export function createMockTransaction( - mockData: { - selectData?: DatabaseSelectResult[] - insertResult?: DatabaseInsertResult[] - updateResult?: DatabaseUpdateResult[] - deleteResult?: DatabaseDeleteResult[] - } = {} -) { - const { selectData = [], insertResult = [], updateResult = [], deleteResult = [] } = mockData - - return vi.fn().mockImplementation(async (callback: any) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue(selectData), - }), - }), - }), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockReturnValue(insertResult), - }), - }), - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue(updateResult), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue(deleteResult), - }), - } - return await callback(tx) - }) -} diff --git a/apps/sim/app/api/auth/forget-password/route.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index 36cbb3e0e8..7f08c76e3e 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -3,13 +3,60 @@ * * @vitest-environment node */ +import { + createMockRequest, + mockConsoleLogger, + mockCryptoUuid, + mockDrizzleOrm, + mockUuid, + setupCommonApiMocks, +} from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils' vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: vi.fn(() => 'https://app.example.com'), })) +/** Setup auth API mocks for testing authentication routes */ +function setupAuthApiMocks( + options: { + operations?: { + forgetPassword?: { success?: boolean; error?: string } + resetPassword?: { success?: boolean; error?: string } + } + } = {} +) { + setupCommonApiMocks() + mockUuid() + mockCryptoUuid() + mockConsoleLogger() + mockDrizzleOrm() + + const { operations = {} } = options + const defaultOperations = { + forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword }, + resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword }, + } + + const createAuthMethod = (config: { success?: boolean; error?: string }) => { + return vi.fn().mockImplementation(() => { + if (config.success) { + return Promise.resolve() + } + return Promise.reject(new Error(config.error)) + }) + } + + vi.doMock('@/lib/auth', () => ({ + auth: { + api: { + forgetPassword: createAuthMethod(defaultOperations.forgetPassword), + resetPassword: createAuthMethod(defaultOperations.resetPassword), + }, + }, + })) +} + describe('Forget Password API Route', () => { beforeEach(() => { vi.resetModules() diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 35bdcbc152..688f72edc7 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -3,8 +3,8 @@ * * @vitest-environment node */ +import { createMockLogger, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils' describe('OAuth Connections API Route', () => { const mockGetSession = vi.fn() diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 93aceaccc1..c83ed6625a 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -4,9 +4,9 @@ * @vitest-environment node */ +import { createMockLogger } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockLogger } from '@/app/api/__test-utils__/utils' describe('OAuth Credentials API Route', () => { const mockGetSession = vi.fn() diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 7f625d2539..9a504982af 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -3,8 +3,8 @@ * * @vitest-environment node */ +import { createMockLogger, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils' describe('OAuth Disconnect API Route', () => { const mockGetSession = vi.fn() diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 7359361a40..c5032fc326 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -3,8 +3,8 @@ * * @vitest-environment node */ +import { createMockLogger, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils' describe('OAuth Token API Routes', () => { const mockGetUserId = vi.fn() diff --git a/apps/sim/app/api/auth/reset-password/route.test.ts b/apps/sim/app/api/auth/reset-password/route.test.ts index 9c9f2df5f9..18c4404440 100644 --- a/apps/sim/app/api/auth/reset-password/route.test.ts +++ b/apps/sim/app/api/auth/reset-password/route.test.ts @@ -3,8 +3,55 @@ * * @vitest-environment node */ +import { + createMockRequest, + mockConsoleLogger, + mockCryptoUuid, + mockDrizzleOrm, + mockUuid, + setupCommonApiMocks, +} from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils' + +/** Setup auth API mocks for testing authentication routes */ +function setupAuthApiMocks( + options: { + operations?: { + forgetPassword?: { success?: boolean; error?: string } + resetPassword?: { success?: boolean; error?: string } + } + } = {} +) { + setupCommonApiMocks() + mockUuid() + mockCryptoUuid() + mockConsoleLogger() + mockDrizzleOrm() + + const { operations = {} } = options + const defaultOperations = { + forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword }, + resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword }, + } + + const createAuthMethod = (config: { success?: boolean; error?: string }) => { + return vi.fn().mockImplementation(() => { + if (config.success) { + return Promise.resolve() + } + return Promise.reject(new Error(config.error)) + }) + } + + vi.doMock('@/lib/auth', () => ({ + auth: { + api: { + forgetPassword: createAuthMethod(defaultOperations.forgetPassword), + resetPassword: createAuthMethod(defaultOperations.resetPassword), + }, + }, + })) +} describe('Reset Password API Route', () => { beforeEach(() => { diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index efc89bc0f4..5a753fd4d9 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -5,7 +5,34 @@ */ import { loggerMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest } from '@/app/api/__test-utils__/utils' + +/** + * Creates a mock NextRequest with cookies support for testing. + */ +function createMockNextRequest( + method = 'GET', + body?: unknown, + headers: Record = {}, + url = 'http://localhost:3000/api/test' +): any { + const headersObj = new Headers({ + 'Content-Type': 'application/json', + ...headers, + }) + + return { + method, + headers: headersObj, + cookies: { + get: vi.fn().mockReturnValue(undefined), + }, + json: + body !== undefined + ? vi.fn().mockResolvedValue(body) + : vi.fn().mockRejectedValue(new Error('No body')), + url, + } +} const createMockStream = () => { return new ReadableStream({ @@ -71,10 +98,15 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('test-request-id'), })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: vi.fn().mockResolvedValue({ decrypted: 'test-password' }), +})) + describe('Chat Identifier API Route', () => { const mockAddCorsHeaders = vi.fn().mockImplementation((response) => response) const mockValidateChatAuth = vi.fn().mockResolvedValue({ authorized: true }) const mockSetChatAuthCookie = vi.fn() + const mockValidateAuthToken = vi.fn().mockReturnValue(false) const mockChatResult = [ { @@ -114,11 +146,16 @@ describe('Chat Identifier API Route', () => { beforeEach(() => { vi.resetModules() - vi.doMock('@/app/api/chat/utils', () => ({ + vi.doMock('@/lib/core/security/deployment', () => ({ addCorsHeaders: mockAddCorsHeaders, + validateAuthToken: mockValidateAuthToken, + setDeploymentAuthCookie: vi.fn(), + isEmailAllowed: vi.fn().mockReturnValue(false), + })) + + vi.doMock('@/app/api/chat/utils', () => ({ validateChatAuth: mockValidateChatAuth, setChatAuthCookie: mockSetChatAuthCookie, - validateAuthToken: vi.fn().mockReturnValue(true), })) // Mock logger - use loggerMock from @sim/testing @@ -175,7 +212,7 @@ describe('Chat Identifier API Route', () => { describe('GET endpoint', () => { it('should return chat info for a valid identifier', async () => { - const req = createMockRequest('GET') + const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'test-chat' }) const { GET } = await import('@/app/api/chat/[identifier]/route') @@ -206,7 +243,7 @@ describe('Chat Identifier API Route', () => { } }) - const req = createMockRequest('GET') + const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'nonexistent' }) const { GET } = await import('@/app/api/chat/[identifier]/route') @@ -240,7 +277,7 @@ describe('Chat Identifier API Route', () => { } }) - const req = createMockRequest('GET') + const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'inactive-chat' }) const { GET } = await import('@/app/api/chat/[identifier]/route') @@ -261,7 +298,7 @@ describe('Chat Identifier API Route', () => { error: 'auth_required_password', })) - const req = createMockRequest('GET') + const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'password-protected-chat' }) const { GET } = await import('@/app/api/chat/[identifier]/route') @@ -282,7 +319,7 @@ describe('Chat Identifier API Route', () => { describe('POST endpoint', () => { it('should handle authentication requests without input', async () => { - const req = createMockRequest('POST', { password: 'test-password' }) + const req = createMockNextRequest('POST', { password: 'test-password' }) const params = Promise.resolve({ identifier: 'password-protected-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') @@ -298,7 +335,7 @@ describe('Chat Identifier API Route', () => { }) it('should return 400 for requests without input', async () => { - const req = createMockRequest('POST', {}) + const req = createMockNextRequest('POST', {}) const params = Promise.resolve({ identifier: 'test-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') @@ -319,7 +356,7 @@ describe('Chat Identifier API Route', () => { error: 'Authentication required', })) - const req = createMockRequest('POST', { input: 'Hello' }) + const req = createMockNextRequest('POST', { input: 'Hello' }) const params = Promise.resolve({ identifier: 'protected-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') @@ -350,7 +387,7 @@ describe('Chat Identifier API Route', () => { }, }) - const req = createMockRequest('POST', { input: 'Hello' }) + const req = createMockNextRequest('POST', { input: 'Hello' }) const params = Promise.resolve({ identifier: 'test-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') @@ -369,7 +406,10 @@ describe('Chat Identifier API Route', () => { }) it('should return streaming response for valid chat messages', async () => { - const req = createMockRequest('POST', { input: 'Hello world', conversationId: 'conv-123' }) + const req = createMockNextRequest('POST', { + input: 'Hello world', + conversationId: 'conv-123', + }) const params = Promise.resolve({ identifier: 'test-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') @@ -401,7 +441,7 @@ describe('Chat Identifier API Route', () => { }, 10000) it('should handle streaming response body correctly', async () => { - const req = createMockRequest('POST', { input: 'Hello world' }) + const req = createMockNextRequest('POST', { input: 'Hello world' }) const params = Promise.resolve({ identifier: 'test-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') @@ -431,7 +471,7 @@ describe('Chat Identifier API Route', () => { throw new Error('Execution failed') }) - const req = createMockRequest('POST', { input: 'Trigger error' }) + const req = createMockNextRequest('POST', { input: 'Trigger error' }) const params = Promise.resolve({ identifier: 'test-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') @@ -470,7 +510,7 @@ describe('Chat Identifier API Route', () => { }) it('should pass conversationId to streaming execution when provided', async () => { - const req = createMockRequest('POST', { + const req = createMockNextRequest('POST', { input: 'Hello world', conversationId: 'test-conversation-123', }) @@ -492,7 +532,7 @@ describe('Chat Identifier API Route', () => { }) it('should handle missing conversationId gracefully', async () => { - const req = createMockRequest('POST', { input: 'Hello world' }) + const req = createMockNextRequest('POST', { input: 'Hello world' }) const params = Promise.resolve({ identifier: 'test-chat' }) const { POST } = await import('@/app/api/chat/[identifier]/route') diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts index b5d27be6e1..8b8f630a09 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -3,9 +3,9 @@ * * @vitest-environment node */ +import { mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@/app/api/__test-utils__/utils' describe('Copilot API Keys API Route', () => { const mockFetch = vi.fn() diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index af36cfb5e0..3b19bc262e 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -3,14 +3,9 @@ * * @vitest-environment node */ +import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockAuth, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' describe('Copilot Chat Delete API Route', () => { const mockDelete = vi.fn() diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index 4ab1e654b9..a196215307 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -3,14 +3,9 @@ * * @vitest-environment node */ +import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockAuth, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' describe('Copilot Chat Update Messages API Route', () => { const mockSelect = vi.fn() diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index 8cc3bb04e5..71e74e053b 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -3,8 +3,8 @@ * * @vitest-environment node */ +import { mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mockCryptoUuid, setupCommonApiMocks } from '@/app/api/__test-utils__/utils' describe('Copilot Chats List API Route', () => { const mockSelect = vi.fn() diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 9725413985..cd5c46d9e1 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -3,14 +3,9 @@ * * @vitest-environment node */ +import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockAuth, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' describe('Copilot Checkpoints Revert API Route', () => { const mockSelect = vi.fn() diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index a344573398..5a15e37b13 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -3,14 +3,9 @@ * * @vitest-environment node */ +import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockAuth, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' describe('Copilot Checkpoints API Route', () => { const mockSelect = vi.fn() diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index 6fc1bfa7e8..5bb9efd684 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -3,14 +3,9 @@ * * @vitest-environment node */ +import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockAuth, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' describe('Copilot Confirm API Route', () => { const mockRedisExists = vi.fn() diff --git a/apps/sim/app/api/copilot/feedback/route.test.ts b/apps/sim/app/api/copilot/feedback/route.test.ts index 547d5cd3b9..5752d7a5af 100644 --- a/apps/sim/app/api/copilot/feedback/route.test.ts +++ b/apps/sim/app/api/copilot/feedback/route.test.ts @@ -3,13 +3,9 @@ * * @vitest-environment node */ +import { createMockRequest, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' describe('Copilot Feedback API Route', () => { const mockInsert = vi.fn() diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts index 0d06c5edd9..35a0ad1dfc 100644 --- a/apps/sim/app/api/copilot/stats/route.test.ts +++ b/apps/sim/app/api/copilot/stats/route.test.ts @@ -3,13 +3,9 @@ * * @vitest-environment node */ +import { createMockRequest, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' describe('Copilot Stats API Route', () => { const mockFetch = vi.fn() diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index 150358c4d2..669ea86ad4 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -1,5 +1,87 @@ +import { + createMockRequest, + mockAuth, + mockCryptoUuid, + mockUuid, + setupCommonApiMocks, +} from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, setupFileApiMocks } from '@/app/api/__test-utils__/utils' + +/** Setup file API mocks for file delete tests */ +function setupFileApiMocks( + options: { + authenticated?: boolean + storageProvider?: 's3' | 'blob' | 'local' + cloudEnabled?: boolean + } = {} +) { + const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options + + setupCommonApiMocks() + mockUuid() + mockCryptoUuid() + + const authMocks = mockAuth() + if (authenticated) { + authMocks.setAuthenticated() + } else { + authMocks.setUnauthenticated() + } + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), + })) + + const uploadFileMock = vi.fn().mockResolvedValue({ + path: '/api/files/serve/test-key.txt', + key: 'test-key.txt', + name: 'test.txt', + size: 100, + type: 'text/plain', + }) + const downloadFileMock = vi.fn().mockResolvedValue(Buffer.from('test content')) + const deleteFileMock = vi.fn().mockResolvedValue(undefined) + const hasCloudStorageMock = vi.fn().mockReturnValue(cloudEnabled) + + vi.doMock('@/lib/uploads', () => ({ + getStorageProvider: vi.fn().mockReturnValue(storageProvider), + isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), + StorageService: { + uploadFile: uploadFileMock, + downloadFile: downloadFileMock, + deleteFile: deleteFileMock, + hasCloudStorage: hasCloudStorageMock, + }, + uploadFile: uploadFileMock, + downloadFile: downloadFileMock, + deleteFile: deleteFileMock, + hasCloudStorage: hasCloudStorageMock, + })) + + vi.doMock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: uploadFileMock, + downloadFile: downloadFileMock, + deleteFile: deleteFileMock, + hasCloudStorage: hasCloudStorageMock, + })) + + vi.doMock('fs/promises', () => ({ + unlink: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ isFile: () => true }), + })) + + return { auth: authMocks } +} describe('File Delete API Route', () => { beforeEach(() => { diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index fa0793648d..801795570a 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -1,12 +1,59 @@ import path from 'path' -import { NextRequest } from 'next/server' /** * Tests for file parse API route * * @vitest-environment node */ +import { + createMockRequest, + mockAuth, + mockCryptoUuid, + mockUuid, + setupCommonApiMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, setupFileApiMocks } from '@/app/api/__test-utils__/utils' + +function setupFileApiMocks( + options: { + authenticated?: boolean + storageProvider?: 's3' | 'blob' | 'local' + cloudEnabled?: boolean + } = {} +) { + const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options + + setupCommonApiMocks() + mockUuid() + mockCryptoUuid() + + const authMocks = mockAuth() + if (authenticated) { + authMocks.setAuthenticated() + } else { + authMocks.setUnauthenticated() + } + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), + })) + + vi.doMock('@/lib/uploads', () => ({ + getStorageProvider: vi.fn().mockReturnValue(storageProvider), + isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), + })) + + return { auth: authMocks } +} const mockJoin = vi.fn((...args: string[]): string => { if (args[0] === '/test/uploads') { diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 6dcac5c62b..0721269382 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -1,6 +1,6 @@ +import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setupFileApiMocks } from '@/app/api/__test-utils__/utils' /** * Tests for file presigned API route @@ -8,6 +8,106 @@ import { setupFileApiMocks } from '@/app/api/__test-utils__/utils' * @vitest-environment node */ +function setupFileApiMocks( + options: { + authenticated?: boolean + storageProvider?: 's3' | 'blob' | 'local' + cloudEnabled?: boolean + } = {} +) { + const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options + + setupCommonApiMocks() + mockUuid() + mockCryptoUuid() + + const authMocks = mockAuth() + if (authenticated) { + authMocks.setAuthenticated() + } else { + authMocks.setUnauthenticated() + } + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), + })) + + const useBlobStorage = storageProvider === 'blob' && cloudEnabled + const useS3Storage = storageProvider === 's3' && cloudEnabled + + vi.doMock('@/lib/uploads/config', () => ({ + USE_BLOB_STORAGE: useBlobStorage, + USE_S3_STORAGE: useS3Storage, + UPLOAD_DIR: '/uploads', + getStorageConfig: vi.fn().mockReturnValue( + useBlobStorage + ? { + accountName: 'testaccount', + accountKey: 'testkey', + connectionString: 'testconnection', + containerName: 'testcontainer', + } + : { + bucket: 'test-bucket', + region: 'us-east-1', + } + ), + isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), + getStorageProvider: vi + .fn() + .mockReturnValue( + storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' + ), + })) + + const mockGeneratePresignedUploadUrl = vi.fn().mockImplementation(async (opts) => { + const timestamp = Date.now() + const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') + const key = `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}` + return { + url: 'https://example.com/presigned-url', + key, + } + }) + + vi.doMock('@/lib/uploads/core/storage-service', () => ({ + hasCloudStorage: vi.fn().mockReturnValue(cloudEnabled), + generatePresignedUploadUrl: mockGeneratePresignedUploadUrl, + generatePresignedDownloadUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'), + })) + + vi.doMock('@/lib/uploads/utils/validation', () => ({ + validateFileType: vi.fn().mockReturnValue(null), + })) + + vi.doMock('@/lib/uploads', () => ({ + CopilotFiles: { + generateCopilotUploadUrl: vi.fn().mockResolvedValue({ + url: 'https://example.com/presigned-url', + key: 'copilot/test-key.txt', + }), + isImageFileType: vi.fn().mockReturnValue(true), + }, + getStorageProvider: vi + .fn() + .mockReturnValue( + storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' + ), + isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), + })) + + return { auth: authMocks } +} + describe('/api/files/presigned', () => { beforeEach(() => { vi.clearAllMocks() @@ -210,7 +310,7 @@ describe('/api/files/presigned', () => { const data = await response.json() expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/) + expect(data.fileInfo.key).toMatch(/^knowledge-base\/.*knowledge-doc\.pdf$/) expect(data.directUploadSupported).toBe(true) }) diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index e5ce18bb8b..fe833f3aa3 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -1,11 +1,49 @@ -import { NextRequest } from 'next/server' /** * Tests for file serve API route * * @vitest-environment node */ +import { + defaultMockUser, + mockAuth, + mockCryptoUuid, + mockUuid, + setupCommonApiMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setupApiTestMocks } from '@/app/api/__test-utils__/utils' + +function setupApiTestMocks( + options: { + authenticated?: boolean + user?: { id: string; email: string } + withFileSystem?: boolean + withUploadUtils?: boolean + } = {} +) { + const { authenticated = true, user = defaultMockUser, withFileSystem = false } = options + + setupCommonApiMocks() + mockUuid() + mockCryptoUuid() + + const authMocks = mockAuth(user) + if (authenticated) { + authMocks.setAuthenticated(user) + } else { + authMocks.setUnauthenticated() + } + + if (withFileSystem) { + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(Buffer.from('test content')), + access: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ isFile: () => true, size: 100 }), + })) + } + + return { auth: authMocks } +} describe('File Serve API Route', () => { beforeEach(() => { @@ -31,6 +69,17 @@ describe('File Serve API Route', () => { existsSync: vi.fn().mockReturnValue(true), })) + vi.doMock('@/lib/uploads', () => ({ + CopilotFiles: { + downloadCopilotFile: vi.fn(), + }, + isUsingCloudStorage: vi.fn().mockReturnValue(false), + })) + + vi.doMock('@/lib/uploads/utils/file-utils', () => ({ + inferContextFromKey: vi.fn().mockReturnValue('workspace'), + })) + vi.doMock('@/app/api/files/utils', () => ({ FileNotFoundError: class FileNotFoundError extends Error { constructor(message: string) { @@ -126,6 +175,17 @@ describe('File Serve API Route', () => { verifyFileAccess: vi.fn().mockResolvedValue(true), })) + vi.doMock('@/lib/uploads', () => ({ + CopilotFiles: { + downloadCopilotFile: vi.fn(), + }, + isUsingCloudStorage: vi.fn().mockReturnValue(false), + })) + + vi.doMock('@/lib/uploads/utils/file-utils', () => ({ + inferContextFromKey: vi.fn().mockReturnValue('workspace'), + })) + const req = new NextRequest( 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nested-path-file.txt' ) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index 35f580abd8..a5ecc030b8 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -1,11 +1,76 @@ -import { NextRequest } from 'next/server' /** * Tests for file upload API route * * @vitest-environment node */ +import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing' +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setupFileApiMocks } from '@/app/api/__test-utils__/utils' + +function setupFileApiMocks( + options: { + authenticated?: boolean + storageProvider?: 's3' | 'blob' | 'local' + cloudEnabled?: boolean + } = {} +) { + const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options + + setupCommonApiMocks() + mockUuid() + mockCryptoUuid() + + const authMocks = mockAuth() + if (authenticated) { + authMocks.setAuthenticated() + } else { + authMocks.setUnauthenticated() + } + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn().mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }), + })) + + vi.doMock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn().mockResolvedValue(true), + verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), + verifyKBFileAccess: vi.fn().mockResolvedValue(true), + verifyCopilotFileAccess: vi.fn().mockResolvedValue(true), + })) + + vi.doMock('@/lib/uploads/contexts/workspace', () => ({ + uploadWorkspaceFile: vi.fn().mockResolvedValue({ + id: 'test-file-id', + name: 'test.txt', + url: '/api/files/serve/workspace/test-workspace-id/test-file.txt', + size: 100, + type: 'text/plain', + key: 'workspace/test-workspace-id/1234567890-test.txt', + uploadedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }), + })) + + const uploadFileMock = vi.fn().mockResolvedValue({ + path: '/api/files/serve/test-key.txt', + key: 'test-key.txt', + name: 'test.txt', + size: 100, + type: 'text/plain', + }) + + vi.doMock('@/lib/uploads', () => ({ + getStorageProvider: vi.fn().mockReturnValue(storageProvider), + isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), + uploadFile: uploadFileMock, + })) + + return { auth: authMocks } +} describe('File Upload API Route', () => { const createMockFormData = (files: File[], context = 'workspace'): FormData => { diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 5b5f3c8c28..ce25228802 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -3,15 +3,24 @@ * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - type CapturedFolderValues, createMockRequest, type MockUser, mockAuth, - mockLogger, + mockConsoleLogger, setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** Type for captured folder values in tests */ +interface CapturedFolderValues { + name?: string + color?: string + parentId?: string | null + isExpanded?: boolean + sortOrder?: number + updatedAt?: Date +} interface FolderDbMockOptions { folderLookupResult?: any @@ -21,6 +30,8 @@ interface FolderDbMockOptions { } describe('Individual Folder API Route', () => { + let mockLogger: ReturnType + const TEST_USER: MockUser = { id: 'user-123', email: 'test@example.com', @@ -39,7 +50,8 @@ describe('Individual Folder API Route', () => { updatedAt: new Date('2024-01-01T00:00:00Z'), } - const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth(TEST_USER) + let mockAuthenticatedUser: (user?: MockUser) => void + let mockUnauthenticated: () => void const mockGetUserEntityPermissions = vi.fn() function createFolderDbMock(options: FolderDbMockOptions = {}) { @@ -110,6 +122,10 @@ describe('Individual Folder API Route', () => { vi.resetModules() vi.clearAllMocks() setupCommonApiMocks() + mockLogger = mockConsoleLogger() + const auth = mockAuth(TEST_USER) + mockAuthenticatedUser = auth.mockAuthenticatedUser + mockUnauthenticated = auth.mockUnauthenticated mockGetUserEntityPermissions.mockResolvedValue('admin') diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index d7da4f779c..6ad39d75ec 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -3,17 +3,46 @@ * * @vitest-environment node */ +import { createMockRequest, mockAuth, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - type CapturedFolderValues, - createMockRequest, - createMockTransaction, - mockAuth, - mockLogger, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' + +interface CapturedFolderValues { + name?: string + color?: string + parentId?: string | null + isExpanded?: boolean + sortOrder?: number + updatedAt?: Date +} + +function createMockTransaction(mockData: { + selectData?: Array<{ id: string; [key: string]: unknown }> + insertResult?: Array<{ id: string; [key: string]: unknown }> +}) { + const { selectData = [], insertResult = [] } = mockData + return vi.fn().mockImplementation(async (callback: (tx: unknown) => Promise) => { + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(selectData), + }), + }), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue(insertResult), + }), + }), + } + return await callback(tx) + }) +} describe('Folders API Route', () => { + let mockLogger: ReturnType const mockFolders = [ { id: 'folder-1', @@ -41,7 +70,8 @@ describe('Folders API Route', () => { }, ] - const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth() + let mockAuthenticatedUser: () => void + let mockUnauthenticated: () => void const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef' const mockSelect = vi.fn() @@ -63,6 +93,10 @@ describe('Folders API Route', () => { }) setupCommonApiMocks() + mockLogger = mockConsoleLogger() + const auth = mockAuth() + mockAuthenticatedUser = auth.mockAuthenticatedUser + mockUnauthenticated = auth.mockUnauthenticated mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index bfae3e36e0..907051f92a 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -9,6 +9,7 @@ import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deploymen import { generateRequestId } from '@/lib/core/utils/request' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -34,22 +35,17 @@ async function getWorkflowInputSchema(workflowId: string): Promise { .from(workflowBlocks) .where(eq(workflowBlocks.workflowId, workflowId)) - // Find the start block (starter or start_trigger type) const startBlock = blocks.find( - (block) => block.type === 'starter' || block.type === 'start_trigger' + (block) => + block.type === 'starter' || block.type === 'start_trigger' || block.type === 'input_trigger' ) if (!startBlock) { return [] } - // Extract inputFormat from subBlocks const subBlocks = startBlock.subBlocks as Record | null - if (!subBlocks?.inputFormat?.value) { - return [] - } - - return Array.isArray(subBlocks.inputFormat.value) ? subBlocks.inputFormat.value : [] + return normalizeInputFormatValue(subBlocks?.inputFormat?.value) } catch (error) { logger.error('Error fetching workflow input schema:', error) return [] diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 783b89d1b2..45abbb3212 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -3,10 +3,9 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { createMockRequest, loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest } from '@/app/api/__test-utils__/utils' vi.mock('@/lib/execution/isolated-vm', () => ({ executeInIsolatedVM: vi.fn().mockImplementation(async (req) => { diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index 710d9eea83..6b63ac13fc 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -3,14 +3,14 @@ * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest, mockAuth, mockConsoleLogger, mockDrizzleOrm, mockKnowledgeSchemas, -} from '@/app/api/__test-utils__/utils' +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' mockKnowledgeSchemas() diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 2b22613f6e..e826de12d7 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -3,14 +3,14 @@ * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest, mockAuth, mockConsoleLogger, mockDrizzleOrm, mockKnowledgeSchemas, -} from '@/app/api/__test-utils__/utils' +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' mockKnowledgeSchemas() diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index 9d64bf5caf..20bbc710f9 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -3,14 +3,14 @@ * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest, mockAuth, mockConsoleLogger, mockDrizzleOrm, mockKnowledgeSchemas, -} from '@/app/api/__test-utils__/utils' +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' mockKnowledgeSchemas() mockDrizzleOrm() diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index e72e7671a3..2a59f45409 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -3,14 +3,14 @@ * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest, mockAuth, mockConsoleLogger, mockDrizzleOrm, mockKnowledgeSchemas, -} from '@/app/api/__test-utils__/utils' +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' mockKnowledgeSchemas() mockDrizzleOrm() diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 04259062e7..d5748b1063 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -5,13 +5,13 @@ * * @vitest-environment node */ -import { createEnvMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + createEnvMock, createMockRequest, mockConsoleLogger, mockKnowledgeSchemas, -} from '@/app/api/__test-utils__/utils' +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('drizzle-orm', () => ({ and: vi.fn().mockImplementation((...args) => ({ and: args })), diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index da83f66153..1d990546c4 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -3,10 +3,9 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { createMockRequest, loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest } from '@/app/api/__test-utils__/utils' describe('Custom Tools API Routes', () => { const sampleTools = [ @@ -364,7 +363,7 @@ describe('Custom Tools API Routes', () => { }) it('should reject requests missing tool ID', async () => { - const req = createMockRequest('DELETE') + const req = new NextRequest('http://localhost:3000/api/tools/custom') const { DELETE } = await import('@/app/api/tools/custom/route') diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index fff521ca8f..737e5ac48b 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -3,15 +3,92 @@ * * @vitest-environment node */ - -import { loggerMock } from '@sim/testing' +import { createMockRequest, loggerMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - globalMockData, - mockExecutionDependencies, - mockTriggerDevSdk, -} from '@/app/api/__test-utils__/utils' + +/** Mock execution dependencies for webhook tests */ +function mockExecutionDependencies() { + vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: vi.fn().mockResolvedValue({ decrypted: 'decrypted-value' }), + })) + + vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({ + buildTraceSpans: vi.fn().mockReturnValue({ traceSpans: [], totalDuration: 100 }), + })) + + vi.mock('@/lib/workflows/utils', () => ({ + updateWorkflowRunCounts: vi.fn().mockResolvedValue(undefined), + })) + + vi.mock('@/serializer', () => ({ + Serializer: vi.fn().mockImplementation(() => ({ + serializeWorkflow: vi.fn().mockReturnValue({ + version: '1.0', + blocks: [ + { + id: 'starter-id', + metadata: { id: 'starter', name: 'Start' }, + config: {}, + inputs: {}, + outputs: {}, + position: { x: 100, y: 100 }, + enabled: true, + }, + { + id: 'agent-id', + metadata: { id: 'agent', name: 'Agent 1' }, + config: {}, + inputs: {}, + outputs: {}, + position: { x: 634, y: -167 }, + enabled: true, + }, + ], + edges: [ + { + id: 'edge-1', + source: 'starter-id', + target: 'agent-id', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + loops: {}, + parallels: {}, + }), + })), + })) +} + +/** Mock Trigger.dev SDK */ +function mockTriggerDevSdk() { + vi.mock('@trigger.dev/sdk', () => ({ + tasks: { trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }) }, + task: vi.fn().mockReturnValue({}), + })) +} + +/** + * Test data store - isolated per test via beforeEach reset + * This replaces the global mutable state pattern with local test data + */ +const testData = { + webhooks: [] as Array<{ + id: string + provider: string + path: string + isActive: boolean + providerConfig?: Record + workflowId: string + rateLimitCount?: number + rateLimitPeriod?: number + }>, + workflows: [] as Array<{ + id: string + userId: string + workspaceId?: string + }>, +} const { generateRequestHashMock, @@ -159,8 +236,8 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ vi.mock('@/lib/webhooks/processor', () => ({ findAllWebhooksForPath: vi.fn().mockImplementation(async (options: { path: string }) => { - // Filter webhooks by path from globalMockData - const matchingWebhooks = globalMockData.webhooks.filter( + // Filter webhooks by path from testData + const matchingWebhooks = testData.webhooks.filter( (wh) => wh.path === options.path && wh.isActive ) @@ -170,7 +247,7 @@ vi.mock('@/lib/webhooks/processor', () => ({ // Return array of {webhook, workflow} objects return matchingWebhooks.map((wh) => { - const matchingWorkflow = globalMockData.workflows.find((w) => w.id === wh.workflowId) || { + const matchingWorkflow = testData.workflows.find((w) => w.id === wh.workflowId) || { id: wh.workflowId || 'test-workflow-id', userId: 'test-user-id', workspaceId: 'test-workspace-id', @@ -283,14 +360,15 @@ describe('Webhook Trigger API Route', () => { beforeEach(() => { vi.clearAllMocks() - globalMockData.webhooks.length = 0 - globalMockData.workflows.length = 0 - globalMockData.schedules.length = 0 + // Reset test data arrays + testData.webhooks.length = 0 + testData.workflows.length = 0 mockExecutionDependencies() mockTriggerDevSdk() - globalMockData.workflows.push({ + // Set up default workflow for tests + testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id', workspaceId: 'test-workspace-id', @@ -326,7 +404,7 @@ describe('Webhook Trigger API Route', () => { describe('Generic Webhook Authentication', () => { it('should process generic webhook without authentication', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -336,7 +414,7 @@ describe('Webhook Trigger API Route', () => { rateLimitCount: 100, rateLimitPeriod: 60, }) - globalMockData.workflows.push({ + testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id', workspaceId: 'test-workspace-id', @@ -354,7 +432,7 @@ describe('Webhook Trigger API Route', () => { }) it('should authenticate with Bearer token when no custom header is configured', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -362,7 +440,7 @@ describe('Webhook Trigger API Route', () => { providerConfig: { requireAuth: true, token: 'test-token-123' }, workflowId: 'test-workflow-id', }) - globalMockData.workflows.push({ + testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id', workspaceId: 'test-workspace-id', @@ -381,7 +459,7 @@ describe('Webhook Trigger API Route', () => { }) it('should authenticate with custom header when configured', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -393,7 +471,7 @@ describe('Webhook Trigger API Route', () => { }, workflowId: 'test-workflow-id', }) - globalMockData.workflows.push({ + testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id', workspaceId: 'test-workspace-id', @@ -412,7 +490,7 @@ describe('Webhook Trigger API Route', () => { }) it('should handle case insensitive Bearer token authentication', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -420,7 +498,7 @@ describe('Webhook Trigger API Route', () => { providerConfig: { requireAuth: true, token: 'case-test-token' }, workflowId: 'test-workflow-id', }) - globalMockData.workflows.push({ + testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id', workspaceId: 'test-workspace-id', @@ -454,7 +532,7 @@ describe('Webhook Trigger API Route', () => { }) it('should handle case insensitive custom header authentication', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -466,7 +544,7 @@ describe('Webhook Trigger API Route', () => { }, workflowId: 'test-workflow-id', }) - globalMockData.workflows.push({ + testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id', workspaceId: 'test-workspace-id', @@ -495,7 +573,7 @@ describe('Webhook Trigger API Route', () => { }) it('should reject wrong Bearer token', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -519,7 +597,7 @@ describe('Webhook Trigger API Route', () => { }) it('should reject wrong custom header token', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -547,7 +625,7 @@ describe('Webhook Trigger API Route', () => { }) it('should reject missing authentication when required', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -567,7 +645,7 @@ describe('Webhook Trigger API Route', () => { }) it('should reject Bearer token when custom header is configured', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -595,7 +673,7 @@ describe('Webhook Trigger API Route', () => { }) it('should reject wrong custom header name', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -623,7 +701,7 @@ describe('Webhook Trigger API Route', () => { }) it('should reject when auth is required but no token is configured', async () => { - globalMockData.webhooks.push({ + testData.webhooks.push({ id: 'generic-webhook-id', provider: 'generic', path: 'test-path', @@ -631,7 +709,7 @@ describe('Webhook Trigger API Route', () => { providerConfig: { requireAuth: true }, workflowId: 'test-workflow-id', }) - globalMockData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id' }) + testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id' }) const headers = { 'Content-Type': 'application/json', diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index b2485fa408..949b52ebc4 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -4,29 +4,29 @@ * * @vitest-environment node */ - -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - createMockDatabase, + databaseMock, + defaultMockUser, mockAuth, mockCryptoUuid, - mockUser, setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow Variables API Route', () => { let authMocks: ReturnType - let databaseMocks: ReturnType const mockGetWorkflowAccessContext = vi.fn() beforeEach(() => { vi.resetModules() setupCommonApiMocks() mockCryptoUuid('mock-request-id-12345678') - authMocks = mockAuth(mockUser) + authMocks = mockAuth(defaultMockUser) mockGetWorkflowAccessContext.mockReset() + vi.doMock('@sim/db', () => databaseMock) + vi.doMock('@/lib/workflows/utils', () => ({ getWorkflowAccessContext: mockGetWorkflowAccessContext, })) @@ -203,10 +203,6 @@ describe('Workflow Variables API Route', () => { isWorkspaceOwner: false, }) - databaseMocks = createMockDatabase({ - update: { results: [{}] }, - }) - const variables = { 'var-1': { id: 'var-1', diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index f56e9d0120..202559142a 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -1,5 +1,5 @@ +import { createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils' describe('Workspace Invitations API Route', () => { const mockWorkspace = { id: 'workspace-1', name: 'Test Workspace' } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 7518a35c4d..252b07f64b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -29,7 +29,7 @@ import { extractPathFromOutputId, parseOutputContentSafely, } from '@/lib/core/utils/response-format' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers' import { START_BLOCK_RESERVED_FIELDS } from '@/lib/workflows/types' import { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx index 382fe5e513..d03470f34f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx @@ -22,7 +22,7 @@ import { import { Skeleton } from '@/components/ui' import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types' import { getBaseUrl } from '@/lib/core/utils/urls' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useA2AAgentByWorkflow, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx index 236af44e07..31012a05a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx @@ -14,7 +14,7 @@ import { } from '@/components/emcn' import { Skeleton } from '@/components/ui' import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils' import type { InputFormatField } from '@/lib/workflows/types' import { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx index 7b3dfb973a..c095853df8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { Badge, Input } from '@/components/emcn' import { Label } from '@/components/ui/label' import { cn } from '@/lib/core/utils/cn' @@ -7,39 +7,7 @@ import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' - -/** - * Represents a field in the input format configuration - */ -interface InputFormatField { - name: string - type?: string -} - -/** - * Represents an input trigger block structure - */ -interface InputTriggerBlock { - type: 'input_trigger' | 'start_trigger' - subBlocks?: { - inputFormat?: { value?: InputFormatField[] } - } -} - -/** - * Represents a legacy starter block structure - */ -interface StarterBlockLegacy { - type: 'starter' - subBlocks?: { - inputFormat?: { value?: InputFormatField[] } - } - config?: { - params?: { - inputFormat?: InputFormatField[] - } - } -} +import { useWorkflowInputFields } from '@/hooks/queries/workflows' /** * Props for the InputMappingField component @@ -70,73 +38,6 @@ interface InputMappingProps { disabled?: boolean } -/** - * Type guard to check if a value is an InputTriggerBlock - * @param value - The value to check - * @returns True if the value is an InputTriggerBlock - */ -function isInputTriggerBlock(value: unknown): value is InputTriggerBlock { - const type = (value as { type?: unknown }).type - return ( - !!value && typeof value === 'object' && (type === 'input_trigger' || type === 'start_trigger') - ) -} - -/** - * Type guard to check if a value is a StarterBlockLegacy - * @param value - The value to check - * @returns True if the value is a StarterBlockLegacy - */ -function isStarterBlock(value: unknown): value is StarterBlockLegacy { - return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'starter' -} - -/** - * Type guard to check if a value is an InputFormatField - * @param value - The value to check - * @returns True if the value is an InputFormatField - */ -function isInputFormatField(value: unknown): value is InputFormatField { - if (typeof value !== 'object' || value === null) return false - if (!('name' in value)) return false - const { name, type } = value as { name: unknown; type?: unknown } - if (typeof name !== 'string' || name.trim() === '') return false - if (type !== undefined && typeof type !== 'string') return false - return true -} - -/** - * Extracts input format fields from workflow blocks - * @param blocks - The workflow blocks to extract from - * @returns Array of input format fields or null if not found - */ -function extractInputFormatFields(blocks: Record): InputFormatField[] | null { - const triggerEntry = Object.entries(blocks).find(([, b]) => isInputTriggerBlock(b)) - if (triggerEntry && isInputTriggerBlock(triggerEntry[1])) { - const inputFormat = triggerEntry[1].subBlocks?.inputFormat?.value - if (Array.isArray(inputFormat)) { - return (inputFormat as unknown[]) - .filter(isInputFormatField) - .map((f) => ({ name: f.name, type: f.type })) - } - } - - const starterEntry = Object.entries(blocks).find(([, b]) => isStarterBlock(b)) - if (starterEntry && isStarterBlock(starterEntry[1])) { - const starter = starterEntry[1] - const subBlockFormat = starter.subBlocks?.inputFormat?.value - const legacyParamsFormat = starter.config?.params?.inputFormat - const chosen = Array.isArray(subBlockFormat) ? subBlockFormat : legacyParamsFormat - if (Array.isArray(chosen)) { - return (chosen as unknown[]) - .filter(isInputFormatField) - .map((f) => ({ name: f.name, type: f.type })) - } - } - - return null -} - /** * InputMapping component displays and manages input field mappings for workflow execution * @param props - The component props @@ -168,62 +69,10 @@ export function InputMapping({ const inputRefs = useRef>(new Map()) const overlayRefs = useRef>(new Map()) - const [childInputFields, setChildInputFields] = useState([]) - const [isLoading, setIsLoading] = useState(false) + const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined + const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId) const [collapsedFields, setCollapsedFields] = useState>({}) - useEffect(() => { - let isMounted = true - const controller = new AbortController() - - async function fetchChildSchema() { - if (!selectedWorkflowId) { - if (isMounted) { - setChildInputFields([]) - setIsLoading(false) - } - return - } - - try { - if (isMounted) setIsLoading(true) - - const res = await fetch(`/api/workflows/${selectedWorkflowId}`, { - signal: controller.signal, - }) - - if (!res.ok) { - if (isMounted) { - setChildInputFields([]) - setIsLoading(false) - } - return - } - - const { data } = await res.json() - const blocks = (data?.state?.blocks as Record) || {} - const fields = extractInputFormatFields(blocks) - - if (isMounted) { - setChildInputFields(fields || []) - setIsLoading(false) - } - } catch (error) { - if (isMounted) { - setChildInputFields([]) - setIsLoading(false) - } - } - } - - fetchChildSchema() - - return () => { - isMounted = false - controller.abort() - } - }, [selectedWorkflowId]) - const valueObj: Record = useMemo(() => { if (isPreview && previewValue && typeof previewValue === 'object') { return previewValue as Record diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index ffeed6880d..cb07582380 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -1,7 +1,6 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useQuery } from '@tanstack/react-query' import { Loader2, WrenchIcon, XIcon } from 'lucide-react' import { useParams } from 'next/navigation' import { @@ -61,7 +60,7 @@ import { useCustomTools, } from '@/hooks/queries/custom-tools' import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hooks/queries/mcp' -import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkflowInputFields, useWorkflows } from '@/hooks/queries/workflows' import { usePermissionConfig } from '@/hooks/use-permission-config' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' import { useSettingsModalStore } from '@/stores/modals/settings/store' @@ -645,56 +644,7 @@ function WorkflowInputMapperSyncWrapper({ disabled: boolean workflowId: string }) { - const { data: workflowData, isLoading } = useQuery({ - queryKey: ['workflow-input-fields', workflowId], - queryFn: async () => { - const response = await fetch(`/api/workflows/${workflowId}`) - if (!response.ok) throw new Error('Failed to fetch workflow') - const { data } = await response.json() - return data - }, - enabled: Boolean(workflowId), - staleTime: 60 * 1000, - }) - - const inputFields = useMemo(() => { - if (!workflowData?.state?.blocks) return [] - - const blocks = workflowData.state.blocks as Record - - const triggerEntry = Object.entries(blocks).find( - ([, block]) => - block.type === 'start_trigger' || block.type === 'input_trigger' || block.type === 'starter' - ) - - if (!triggerEntry) return [] - - const triggerBlock = triggerEntry[1] - - const inputFormat = triggerBlock.subBlocks?.inputFormat?.value - - if (Array.isArray(inputFormat)) { - return inputFormat - .filter((field: any) => field.name && typeof field.name === 'string') - .map((field: any) => ({ - name: field.name, - type: field.type || 'string', - })) - } - - const legacyFormat = triggerBlock.config?.params?.inputFormat - - if (Array.isArray(legacyFormat)) { - return legacyFormat - .filter((field: any) => field.name && typeof field.name === 'string') - .map((field: any) => ({ - name: field.name, - type: field.type || 'string', - })) - } - - return [] - }, [workflowData]) + const { data: inputFields = [], isLoading } = useWorkflowInputFields(workflowId) const parsedValue = useMemo(() => { try { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 9dc273c4ea..5a47dc1d78 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -136,7 +136,6 @@ export function WorkspaceHeader({ const [editingWorkspaceId, setEditingWorkspaceId] = useState(null) const [editingName, setEditingName] = useState('') const [isListRenaming, setIsListRenaming] = useState(false) - const listRenameInputRef = useRef(null) const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) @@ -146,6 +145,10 @@ export function WorkspaceHeader({ name: string permissions?: 'admin' | 'write' | 'read' | null } | null>(null) + const isRenamingRef = useRef(false) + const isContextMenuOpeningRef = useRef(false) + const contextMenuClosedRef = useRef(true) + const hasInputFocusedRef = useRef(false) const [isMounted, setIsMounted] = useState(false) useEffect(() => { @@ -165,20 +168,6 @@ export function WorkspaceHeader({ return () => window.removeEventListener('open-invite-modal', handleOpenInvite) }, [isInvitationsDisabled]) - /** - * Focus the inline list rename input when it becomes active - */ - useEffect(() => { - if (editingWorkspaceId && listRenameInputRef.current) { - try { - listRenameInputRef.current.focus() - listRenameInputRef.current.select() - } catch { - // no-op - } - } - }, [editingWorkspaceId]) - /** * Save and exit edit mode when popover closes */ @@ -201,6 +190,9 @@ export function WorkspaceHeader({ e.preventDefault() e.stopPropagation() + isContextMenuOpeningRef.current = true + contextMenuClosedRef.current = false + capturedWorkspaceRef.current = { id: workspace.id, name: workspace.name, @@ -211,11 +203,22 @@ export function WorkspaceHeader({ } /** - * Close context menu and the workspace dropdown + * Close context menu and optionally the workspace dropdown + * When renaming, we keep the workspace menu open so the input is visible + * This function is idempotent - duplicate calls are ignored */ const closeContextMenu = () => { + if (contextMenuClosedRef.current) { + return + } + contextMenuClosedRef.current = true + setIsContextMenuOpen(false) - setIsWorkspaceMenuOpen(false) + isContextMenuOpeningRef.current = false + if (!isRenamingRef.current) { + setIsWorkspaceMenuOpen(false) + } + isRenamingRef.current = false } /** @@ -224,8 +227,11 @@ export function WorkspaceHeader({ const handleRenameAction = () => { if (!capturedWorkspaceRef.current) return + isRenamingRef.current = true + hasInputFocusedRef.current = false setEditingWorkspaceId(capturedWorkspaceRef.current.id) setEditingName(capturedWorkspaceRef.current.name) + setIsWorkspaceMenuOpen(true) } /** @@ -287,8 +293,10 @@ export function WorkspaceHeader({ { - // Don't close if context menu is opening - if (!open && isContextMenuOpen) { + if ( + !open && + (isContextMenuOpen || isContextMenuOpeningRef.current || editingWorkspaceId) + ) { return } setIsWorkspaceMenuOpen(open) @@ -302,6 +310,11 @@ export function WorkspaceHeader({ isCollapsed ? '' : '-mx-[6px] min-w-0 max-w-full' }`} title={activeWorkspace?.name || 'Loading...'} + onContextMenu={(e) => { + if (activeWorkspaceFull) { + handleContextMenu(e, activeWorkspaceFull) + } + }} > { + if (el && !hasInputFocusedRef.current) { + hasInputFocusedRef.current = true + el.focus() + el.select() + } + }} value={editingName} onChange={(e) => setEditingName(e.target.value)} onKeyDown={async (e) => { @@ -406,15 +425,18 @@ export function WorkspaceHeader({ }} onBlur={async () => { if (!editingWorkspaceId) return - setIsListRenaming(true) - try { - await onRenameWorkspace(workspace.id, editingName.trim()) - setEditingWorkspaceId(null) - } finally { - setIsListRenaming(false) + const trimmedName = editingName.trim() + if (trimmedName && trimmedName !== workspace.name) { + setIsListRenaming(true) + try { + await onRenameWorkspace(workspace.id, trimmedName) + } finally { + setIsListRenaming(false) + } } + setEditingWorkspaceId(null) }} - className='w-full border-0 bg-transparent p-0 font-base text-[13px] text-[var(--text-primary)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' + className='w-full border-0 bg-transparent p-0 font-base text-[13px] text-[var(--text-primary)] outline-none selection:bg-[#add6ff] selection:text-[#1b1b1b] focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:selection:bg-[#264f78] dark:selection:text-white' maxLength={100} autoComplete='off' autoCorrect='off' @@ -422,7 +444,6 @@ export function WorkspaceHeader({ spellCheck='false' disabled={isListRenaming} onClick={(e) => { - e.preventDefault() e.stopPropagation() }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts index dafa834499..2b9eee714e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts @@ -161,7 +161,10 @@ export function useWorkspaceManagement({ } // Update local state immediately after successful API call - setActiveWorkspace((prev) => (prev ? { ...prev, name: newName.trim() } : null)) + // Only update activeWorkspace if it's the one being renamed + setActiveWorkspace((prev) => + prev && prev.id === workspaceId ? { ...prev, name: newName.trim() } : prev + ) setWorkspaces((prev) => prev.map((workspace) => workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace diff --git a/apps/sim/executor/__test-utils__/executor-mocks.ts b/apps/sim/executor/__test-utils__/executor-mocks.ts deleted file mode 100644 index efe146ac56..0000000000 --- a/apps/sim/executor/__test-utils__/executor-mocks.ts +++ /dev/null @@ -1,929 +0,0 @@ -import { vi } from 'vitest' -import type { SerializedWorkflow } from '@/serializer/types' - -/** - * Mock handler factory - creates consistent handler mocks - */ -export const createMockHandler = ( - handlerName: string, - options?: { - canHandleCondition?: (block: any) => boolean - executeResult?: any | ((inputs: any) => any) - } -) => { - const defaultCanHandle = (block: any) => - block.metadata?.id === handlerName || handlerName === 'generic' - - const defaultExecuteResult = { - result: `${handlerName} executed`, - } - - return vi.fn().mockImplementation(() => ({ - canHandle: options?.canHandleCondition || defaultCanHandle, - execute: vi.fn().mockImplementation(async (block, inputs) => { - if (typeof options?.executeResult === 'function') { - return options.executeResult(inputs) - } - return options?.executeResult || defaultExecuteResult - }), - })) -} - -/** - * Setup all handler mocks with default behaviors - */ -export const setupHandlerMocks = () => { - vi.doMock('@/executor/handlers', () => ({ - TriggerBlockHandler: createMockHandler('trigger', { - canHandleCondition: (block) => - block.metadata?.category === 'triggers' || block.config?.params?.triggerMode === true, - executeResult: (inputs: any) => inputs || {}, - }), - AgentBlockHandler: createMockHandler('agent'), - RouterBlockHandler: createMockHandler('router'), - ConditionBlockHandler: createMockHandler('condition'), - EvaluatorBlockHandler: createMockHandler('evaluator'), - FunctionBlockHandler: createMockHandler('function'), - ApiBlockHandler: createMockHandler('api'), - LoopBlockHandler: createMockHandler('loop'), - ParallelBlockHandler: createMockHandler('parallel'), - WorkflowBlockHandler: createMockHandler('workflow'), - VariablesBlockHandler: createMockHandler('variables'), - WaitBlockHandler: createMockHandler('wait'), - GenericBlockHandler: createMockHandler('generic'), - ResponseBlockHandler: createMockHandler('response'), - })) -} - -/** - * Setup store mocks with configurable options - */ -export const setupStoreMocks = (options?: { - isDebugging?: boolean - consoleAddFn?: ReturnType - consoleUpdateFn?: ReturnType -}) => { - const consoleAddFn = options?.consoleAddFn || vi.fn() - const consoleUpdateFn = options?.consoleUpdateFn || vi.fn() - - vi.doMock('@/stores/settings/general/store', () => ({ - useGeneralStore: { - getState: () => ({}), - }, - })) - - vi.doMock('@/stores/execution/store', () => ({ - useExecutionStore: { - getState: () => ({ - isDebugging: options?.isDebugging ?? false, - setIsExecuting: vi.fn(), - reset: vi.fn(), - setActiveBlocks: vi.fn(), - setPendingBlocks: vi.fn(), - setIsDebugging: vi.fn(), - }), - setState: vi.fn(), - }, - })) - - vi.doMock('@/stores/console/store', () => ({ - useConsoleStore: { - getState: () => ({ - addConsole: consoleAddFn, - }), - }, - })) - - vi.doMock('@/stores/terminal', () => ({ - useTerminalConsoleStore: { - getState: () => ({ - addConsole: consoleAddFn, - updateConsole: consoleUpdateFn, - }), - }, - })) - - return { consoleAddFn, consoleUpdateFn } -} - -/** - * Setup core executor mocks (PathTracker, InputResolver, LoopManager, ParallelManager) - */ -export const setupExecutorCoreMocks = () => { - vi.doMock('@/executor/path', () => ({ - PathTracker: vi.fn().mockImplementation(() => ({ - updateExecutionPaths: vi.fn(), - isInActivePath: vi.fn().mockReturnValue(true), - })), - })) - - vi.doMock('@/executor/resolver', () => ({ - InputResolver: vi.fn().mockImplementation(() => ({ - resolveInputs: vi.fn().mockReturnValue({}), - resolveBlockReferences: vi.fn().mockImplementation((value) => value), - resolveVariableReferences: vi.fn().mockImplementation((value) => value), - resolveEnvVariables: vi.fn().mockImplementation((value) => value), - })), - })) - - vi.doMock('@/executor/loops', () => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - processLoopIterations: vi.fn().mockResolvedValue(false), - getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => { - return context.loopExecutions?.get(loopId)?.iteration || 0 - }), - })), - })) - - vi.doMock('@/executor/parallels', () => ({ - ParallelManager: vi.fn().mockImplementation(() => ({ - processParallelIterations: vi.fn().mockResolvedValue(false), - createVirtualBlockInstances: vi.fn().mockReturnValue([]), - setupIterationContext: vi.fn(), - storeIterationResult: vi.fn(), - initializeParallel: vi.fn(), - getIterationItem: vi.fn(), - areAllVirtualBlocksExecuted: vi.fn().mockReturnValue(false), - })), - })) -} - -/** - * Workflow factory functions - */ -export const createMinimalWorkflow = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'block1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Test Block' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'block1', - }, - ], - loops: {}, -}) - -export const createWorkflowWithCondition = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'condition1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'condition', name: 'Condition Block' }, - }, - { - id: 'block1', - position: { x: 200, y: -50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'True Path Block' }, - }, - { - id: 'block2', - position: { x: 200, y: 50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'False Path Block' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'condition1', - }, - { - source: 'condition1', - target: 'block1', - sourceHandle: 'condition-true', - }, - { - source: 'condition1', - target: 'block2', - sourceHandle: 'condition-false', - }, - ], - loops: {}, -}) - -export const createWorkflowWithLoop = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'block1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Loop Block 1' }, - }, - { - id: 'block2', - position: { x: 200, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Loop Block 2' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'block1', - }, - { - source: 'block1', - target: 'block2', - }, - { - source: 'block2', - target: 'block1', - }, - ], - loops: { - loop1: { - id: 'loop1', - nodes: ['block1', 'block2'], - iterations: 5, - loopType: 'forEach', - forEachItems: [1, 2, 3, 4, 5], - }, - }, -}) - -export const createWorkflowWithErrorPath = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'block1', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'function', name: 'Function Block' }, - }, - { - id: 'error-handler', - position: { x: 200, y: 50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Error Handler Block' }, - }, - { - id: 'success-block', - position: { x: 200, y: -50 }, - config: { tool: 'test-tool', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - metadata: { id: 'test', name: 'Success Block' }, - }, - ], - connections: [ - { - source: 'starter', - target: 'block1', - }, - { - source: 'block1', - target: 'success-block', - sourceHandle: 'source', - }, - { - source: 'block1', - target: 'error-handler', - sourceHandle: 'error', - }, - ], - loops: {}, -}) - -export const createWorkflowWithParallel = (distribution?: any): SerializedWorkflow => ({ - version: '2.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - metadata: { id: 'starter', name: 'Start' }, - config: { tool: 'starter', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'parallel-1', - position: { x: 100, y: 0 }, - metadata: { id: 'parallel', name: 'Test Parallel' }, - config: { tool: 'parallel', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'function-1', - position: { x: 200, y: 0 }, - metadata: { id: 'function', name: 'Process Item' }, - config: { - tool: 'function', - params: { - code: 'return { item: , index: }', - }, - }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'endpoint', - position: { x: 300, y: 0 }, - metadata: { id: 'generic', name: 'End' }, - config: { tool: 'generic', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - ], - connections: [ - { source: 'starter', target: 'parallel-1' }, - { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, - { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, - ], - loops: {}, - parallels: { - 'parallel-1': { - id: 'parallel-1', - nodes: ['function-1'], - distribution: distribution || ['apple', 'banana', 'cherry'], - }, - }, -}) - -export const createWorkflowWithResponse = (): SerializedWorkflow => ({ - version: '1.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: { - input: 'json', - }, - outputs: { - response: { type: 'json', description: 'Input response' }, - }, - enabled: true, - metadata: { id: 'starter', name: 'Starter Block' }, - }, - { - id: 'response', - position: { x: 100, y: 0 }, - config: { tool: 'test-tool', params: {} }, - inputs: { - data: 'json', - status: 'number', - headers: 'json', - }, - outputs: { - data: { type: 'json', description: 'Response data' }, - status: { type: 'number', description: 'Response status' }, - headers: { type: 'json', description: 'Response headers' }, - }, - enabled: true, - metadata: { id: 'response', name: 'Response Block' }, - }, - ], - connections: [{ source: 'starter', target: 'response' }], - loops: {}, -}) - -/** - * Create a mock execution context with customizable options - */ -export interface MockContextOptions { - workflowId?: string - loopExecutions?: Map - executedBlocks?: Set - activeExecutionPath?: Set - completedLoops?: Set - parallelExecutions?: Map - parallelBlockMapping?: Map - currentVirtualBlockId?: string - workflow?: SerializedWorkflow - blockStates?: Map -} - -export const createMockContext = (options: MockContextOptions = {}) => { - const workflow = options.workflow || createMinimalWorkflow() - - return { - workflowId: options.workflowId || 'test-workflow-id', - blockStates: options.blockStates || new Map(), - blockLogs: [], - metadata: { startTime: new Date().toISOString(), duration: 0 }, - environmentVariables: {}, - decisions: { router: new Map(), condition: new Map() }, - loopExecutions: options.loopExecutions || new Map(), - executedBlocks: options.executedBlocks || new Set(), - activeExecutionPath: options.activeExecutionPath || new Set(), - workflow, - completedLoops: options.completedLoops || new Set(), - parallelExecutions: options.parallelExecutions || new Map(), - parallelBlockMapping: options.parallelBlockMapping, - currentVirtualBlockId: options.currentVirtualBlockId, - } -} - -/** - * Mock implementations for testing loops - */ -export const createLoopManagerMock = (options?: { - processLoopIterationsImpl?: (context: any) => Promise - getLoopIndexImpl?: (loopId: string, blockId: string, context: any) => number -}) => ({ - LoopManager: vi.fn().mockImplementation(() => ({ - processLoopIterations: options?.processLoopIterationsImpl || vi.fn().mockResolvedValue(false), - getLoopIndex: - options?.getLoopIndexImpl || - vi.fn().mockImplementation((loopId, blockId, context) => { - return context.loopExecutions?.get(loopId)?.iteration || 0 - }), - })), -}) - -/** - * Create a parallel execution state object for testing - */ -export const createParallelExecutionState = (options?: { - parallelCount?: number - distributionItems?: any[] | Record | null - completedExecutions?: number - executionResults?: Map - activeIterations?: Set - parallelType?: 'count' | 'collection' -}) => ({ - parallelCount: options?.parallelCount ?? 3, - distributionItems: - options?.distributionItems !== undefined ? options.distributionItems : ['a', 'b', 'c'], - completedExecutions: options?.completedExecutions ?? 0, - executionResults: options?.executionResults ?? new Map(), - activeIterations: options?.activeIterations ?? new Set(), - parallelType: options?.parallelType, -}) - -/** - * Mock implementations for testing parallels - */ -export const createParallelManagerMock = (options?: { - maxChecks?: number - processParallelIterationsImpl?: (context: any) => Promise -}) => ({ - ParallelManager: vi.fn().mockImplementation(() => { - const executionCounts = new Map() - const maxChecks = options?.maxChecks || 2 - - return { - processParallelIterations: - options?.processParallelIterationsImpl || - vi.fn().mockImplementation(async (context) => { - for (const [parallelId, parallel] of Object.entries(context.workflow?.parallels || {})) { - if (context.completedLoops.has(parallelId)) { - continue - } - - const parallelState = context.parallelExecutions?.get(parallelId) - if (!parallelState) { - continue - } - - const checkCount = executionCounts.get(parallelId) || 0 - executionCounts.set(parallelId, checkCount + 1) - - if (checkCount >= maxChecks) { - context.completedLoops.add(parallelId) - continue - } - - let allVirtualBlocksExecuted = true - const parallelNodes = (parallel as any).nodes || [] - for (const nodeId of parallelNodes) { - for (let i = 0; i < parallelState.parallelCount; i++) { - const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` - if (!context.executedBlocks.has(virtualBlockId)) { - allVirtualBlocksExecuted = false - break - } - } - if (!allVirtualBlocksExecuted) break - } - - if (allVirtualBlocksExecuted && !context.completedLoops.has(parallelId)) { - context.executedBlocks.delete(parallelId) - context.activeExecutionPath.add(parallelId) - - for (const nodeId of parallelNodes) { - context.activeExecutionPath.delete(nodeId) - } - } - } - }), - createVirtualBlockInstances: vi.fn().mockImplementation((block, parallelId, state) => { - const instances = [] - for (let i = 0; i < state.parallelCount; i++) { - instances.push(`${block.id}_parallel_${parallelId}_iteration_${i}`) - } - return instances - }), - setupIterationContext: vi.fn(), - storeIterationResult: vi.fn(), - initializeParallel: vi.fn(), - getIterationItem: vi.fn(), - areAllVirtualBlocksExecuted: vi - .fn() - .mockImplementation((parallelId, parallel, executedBlocks, state, context) => { - // Simple mock implementation - check all blocks (ignoring conditional routing for tests) - for (const nodeId of parallel.nodes) { - for (let i = 0; i < state.parallelCount; i++) { - const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` - if (!executedBlocks.has(virtualBlockId)) { - return false - } - } - } - return true - }), - } - }), -}) - -/** - * Setup function block handler that executes code - */ -export const createFunctionBlockHandler = vi.fn().mockImplementation(() => ({ - canHandle: (block: any) => block.metadata?.id === 'function', - execute: vi.fn().mockImplementation(async (block, inputs) => { - return { - result: inputs.code ? new Function(inputs.code)() : { key: inputs.key, value: inputs.value }, - stdout: '', - } - }), -})) - -/** - * Create a custom parallel block handler for testing - */ -export const createParallelBlockHandler = vi.fn().mockImplementation(() => { - return { - canHandle: (block: any) => block.metadata?.id === 'parallel', - execute: vi.fn().mockImplementation(async (block, inputs, context) => { - const parallelId = block.id - const parallel = context.workflow?.parallels?.[parallelId] - - if (!parallel) { - throw new Error('Parallel configuration not found') - } - - if (!context.parallelExecutions) { - context.parallelExecutions = new Map() - } - - let parallelState = context.parallelExecutions.get(parallelId) - - if (!parallelState) { - // First execution - initialize - const distributionItems = parallel.distribution || [] - const parallelCount = Array.isArray(distributionItems) - ? distributionItems.length - : typeof distributionItems === 'object' - ? Object.keys(distributionItems).length - : 1 - - parallelState = { - parallelCount, - distributionItems, - completedExecutions: 0, - executionResults: new Map(), - activeIterations: new Set(), - } - context.parallelExecutions.set(parallelId, parallelState) - - if (distributionItems) { - context.loopItems.set(`${parallelId}_items`, distributionItems) - } - - // Activate child nodes - const connections = - context.workflow?.connections.filter( - (conn: any) => - conn.source === parallelId && conn.sourceHandle === 'parallel-start-source' - ) || [] - - for (const conn of connections) { - context.activeExecutionPath.add(conn.target) - } - - return { - parallelId, - parallelCount, - distributionType: 'distributed', - started: true, - message: `Initialized ${parallelCount} parallel executions`, - } - } - - // Check completion - const allCompleted = parallel.nodes.every((nodeId: string) => { - for (let i = 0; i < parallelState.parallelCount; i++) { - const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}` - if (!context.executedBlocks.has(virtualBlockId)) { - return false - } - } - return true - }) - - if (allCompleted) { - context.completedLoops.add(parallelId) - - // Activate end connections - const endConnections = - context.workflow?.connections.filter( - (conn: any) => conn.source === parallelId && conn.sourceHandle === 'parallel-end-source' - ) || [] - - for (const conn of endConnections) { - context.activeExecutionPath.add(conn.target) - } - - return { - parallelId, - parallelCount: parallelState.parallelCount, - completed: true, - message: `Completed all ${parallelState.parallelCount} executions`, - } - } - - return { - parallelId, - parallelCount: parallelState.parallelCount, - waiting: true, - message: 'Waiting for iterations to complete', - } - }), - } -}) - -/** - * Create an input resolver mock that handles parallel references - */ -export const createParallelInputResolver = (distributionData: any) => ({ - InputResolver: vi.fn().mockImplementation(() => ({ - resolveInputs: vi.fn().mockImplementation((block, context) => { - if (block.metadata?.id === 'function') { - const virtualBlockId = context.currentVirtualBlockId - if (virtualBlockId && context.parallelBlockMapping) { - const mapping = context.parallelBlockMapping.get(virtualBlockId) - if (mapping) { - if (Array.isArray(distributionData)) { - const currentItem = distributionData[mapping.iterationIndex] - const currentIndex = mapping.iterationIndex - return { - code: `return { item: "${currentItem}", index: ${currentIndex} }`, - } - } - if (typeof distributionData === 'object') { - const entries = Object.entries(distributionData) - const [key, value] = entries[mapping.iterationIndex] - return { - code: `return { key: "${key}", value: "${value}" }`, - } - } - } - } - } - return {} - }), - })), -}) - -/** - * Create a workflow with parallel blocks for testing - */ -export const createWorkflowWithParallelArray = ( - items: any[] = ['apple', 'banana', 'cherry'] -): SerializedWorkflow => ({ - version: '2.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - metadata: { id: 'starter', name: 'Start' }, - config: { tool: 'starter', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'parallel-1', - position: { x: 100, y: 0 }, - metadata: { id: 'parallel', name: 'Test Parallel' }, - config: { tool: 'parallel', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'function-1', - position: { x: 200, y: 0 }, - metadata: { id: 'function', name: 'Process Item' }, - config: { - tool: 'function', - params: { - code: 'return { item: , index: }', - }, - }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'endpoint', - position: { x: 300, y: 0 }, - metadata: { id: 'generic', name: 'End' }, - config: { tool: 'generic', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - ], - connections: [ - { source: 'starter', target: 'parallel-1' }, - { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, - { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, - ], - loops: {}, - parallels: { - 'parallel-1': { - id: 'parallel-1', - nodes: ['function-1'], - distribution: items, - }, - }, -}) - -/** - * Create a workflow with parallel blocks for object distribution - */ -export const createWorkflowWithParallelObject = ( - items: Record = { first: 'alpha', second: 'beta', third: 'gamma' } -): SerializedWorkflow => ({ - version: '2.0', - blocks: [ - { - id: 'starter', - position: { x: 0, y: 0 }, - metadata: { id: 'starter', name: 'Start' }, - config: { tool: 'starter', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'parallel-1', - position: { x: 100, y: 0 }, - metadata: { id: 'parallel', name: 'Test Parallel' }, - config: { tool: 'parallel', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'function-1', - position: { x: 200, y: 0 }, - metadata: { id: 'function', name: 'Process Entry' }, - config: { - tool: 'function', - params: { - code: 'return { key: , value: }', - }, - }, - inputs: {}, - outputs: {}, - enabled: true, - }, - { - id: 'endpoint', - position: { x: 300, y: 0 }, - metadata: { id: 'generic', name: 'End' }, - config: { tool: 'generic', params: {} }, - inputs: {}, - outputs: {}, - enabled: true, - }, - ], - connections: [ - { source: 'starter', target: 'parallel-1' }, - { source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' }, - { source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' }, - ], - loops: {}, - parallels: { - 'parallel-1': { - id: 'parallel-1', - nodes: ['function-1'], - distribution: items, - }, - }, -}) - -/** - * Mock all modules needed for parallel tests - */ -export const setupParallelTestMocks = (options?: { - distributionData?: any - maxParallelChecks?: number -}) => { - setupStoreMocks() - - setupExecutorCoreMocks() - - vi.doMock('@/executor/parallels', () => - createParallelManagerMock({ - maxChecks: options?.maxParallelChecks, - }) - ) - - vi.doMock('@/executor/loops', () => createLoopManagerMock()) -} - -/** - * Sets up all standard mocks for executor tests - */ -export const setupAllMocks = (options?: { - isDebugging?: boolean - consoleAddFn?: ReturnType - consoleUpdateFn?: ReturnType -}) => { - setupHandlerMocks() - const storeMocks = setupStoreMocks(options) - setupExecutorCoreMocks() - - return storeMocks -} diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 7a5d06f405..d473472c80 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -266,11 +266,13 @@ export interface ConditionConfig { } export function isTriggerBlockType(blockType: string | undefined): boolean { - return TRIGGER_BLOCK_TYPES.includes(blockType as any) + return blockType !== undefined && (TRIGGER_BLOCK_TYPES as readonly string[]).includes(blockType) } export function isMetadataOnlyBlockType(blockType: string | undefined): boolean { - return METADATA_ONLY_BLOCK_TYPES.includes(blockType as any) + return ( + blockType !== undefined && (METADATA_ONLY_BLOCK_TYPES as readonly string[]).includes(blockType) + ) } export function isWorkflowBlockType(blockType: string | undefined): boolean { diff --git a/apps/sim/executor/handlers/api/api-handler.test.ts b/apps/sim/executor/handlers/api/api-handler.test.ts index 2a5303b9a5..1a930f57ff 100644 --- a/apps/sim/executor/handlers/api/api-handler.test.ts +++ b/apps/sim/executor/handlers/api/api-handler.test.ts @@ -1,4 +1,4 @@ -import '@/executor/__test-utils__/mock-dependencies' +import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index e112a5e5ec..8cbbb0bbf4 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -1,4 +1,4 @@ -import '@/executor/__test-utils__/mock-dependencies' +import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' diff --git a/apps/sim/executor/handlers/generic/generic-handler.test.ts b/apps/sim/executor/handlers/generic/generic-handler.test.ts index dfbe364802..661c7a1244 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.test.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.test.ts @@ -1,4 +1,4 @@ -import '@/executor/__test-utils__/mock-dependencies' +import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 9b2bc654ca..d0e28f97eb 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -1,4 +1,4 @@ -import '@/executor/__test-utils__/mock-dependencies' +import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { generateRouterPrompt } from '@/blocks/blocks/router' diff --git a/apps/sim/executor/handlers/trigger/trigger-handler.test.ts b/apps/sim/executor/handlers/trigger/trigger-handler.test.ts index 0ae4eae63c..c9e1b4da1a 100644 --- a/apps/sim/executor/handlers/trigger/trigger-handler.test.ts +++ b/apps/sim/executor/handlers/trigger/trigger-handler.test.ts @@ -1,4 +1,4 @@ -import '@/executor/__test-utils__/mock-dependencies' +import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it } from 'vitest' import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler' diff --git a/apps/sim/executor/handlers/wait/wait-handler.test.ts b/apps/sim/executor/handlers/wait/wait-handler.test.ts index f9abab07bc..27a4e33bae 100644 --- a/apps/sim/executor/handlers/wait/wait-handler.test.ts +++ b/apps/sim/executor/handlers/wait/wait-handler.test.ts @@ -1,4 +1,4 @@ -import '@/executor/__test-utils__/mock-dependencies' +import '@sim/testing/mocks/executor' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockType } from '@/executor/constants' diff --git a/apps/sim/executor/utils/lazy-cleanup.ts b/apps/sim/executor/utils/lazy-cleanup.ts index e892cbdf99..3ab2dc9238 100644 --- a/apps/sim/executor/utils/lazy-cleanup.ts +++ b/apps/sim/executor/utils/lazy-cleanup.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' const logger = createLogger('LazyCleanup') @@ -12,38 +13,14 @@ const logger = createLogger('LazyCleanup') * @returns Set of valid field names defined in the child's inputFormat */ function extractValidInputFieldNames(childWorkflowBlocks: Record): Set | null { - const validFieldNames = new Set() + const fields = extractInputFieldsFromBlocks(childWorkflowBlocks) - const startBlock = Object.values(childWorkflowBlocks).find((block: any) => { - const blockType = block?.type - return blockType === 'start_trigger' || blockType === 'input_trigger' || blockType === 'starter' - }) - - if (!startBlock) { - logger.debug('No start block found in child workflow') - return null - } - - const inputFormat = - (startBlock as any)?.subBlocks?.inputFormat?.value ?? - (startBlock as any)?.config?.params?.inputFormat - - if (!Array.isArray(inputFormat)) { - logger.debug('No inputFormat array found in child workflow start block') + if (fields.length === 0) { + logger.debug('No inputFormat fields found in child workflow') return null } - // Extract field names - for (const field of inputFormat) { - if (field?.name && typeof field.name === 'string') { - const fieldName = field.name.trim() - if (fieldName) { - validFieldNames.add(fieldName) - } - } - } - - return validFieldNames + return new Set(fields.map((field) => field.name)) } /** diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index ba786b1675..da4f3096c4 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -32,7 +32,7 @@ export class BlockResolver implements Resolver { return false } const [type] = parts - return !SPECIAL_REFERENCE_PREFIXES.includes(type as any) + return !(SPECIAL_REFERENCE_PREFIXES as readonly string[]).includes(type) } resolve(reference: string, context: ResolutionContext): any { diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index b6f5dc0673..e1e8460e7e 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format' import { createOptimisticMutationHandlers, generateTempId, @@ -22,6 +23,34 @@ export const workflowKeys = { deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, deploymentVersion: (workflowId: string | undefined, version: number | undefined) => [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, + inputFields: (workflowId: string | undefined) => + [...workflowKeys.all, 'inputFields', workflowId ?? ''] as const, +} + +/** + * Fetches workflow input fields from the workflow state. + */ +async function fetchWorkflowInputFields(workflowId: string): Promise { + const response = await fetch(`/api/workflows/${workflowId}`) + if (!response.ok) throw new Error('Failed to fetch workflow') + const { data } = await response.json() + return extractInputFieldsFromBlocks(data?.state?.blocks) +} + +/** + * Hook to fetch workflow input fields for configuration. + * Uses React Query for caching and deduplication. + * + * @param workflowId - The workflow ID to fetch input fields for + * @returns Query result with input fields array + */ +export function useWorkflowInputFields(workflowId: string | undefined) { + return useQuery({ + queryKey: workflowKeys.inputFields(workflowId), + queryFn: () => fetchWorkflowInputFields(workflowId!), + enabled: Boolean(workflowId), + staleTime: 60 * 1000, // 1 minute cache + }) } function mapWorkflow(workflow: any): WorkflowMetadata { diff --git a/apps/sim/lib/execution/files.ts b/apps/sim/lib/execution/files.ts index 510856e580..9eb26905e4 100644 --- a/apps/sim/lib/execution/files.ts +++ b/apps/sim/lib/execution/files.ts @@ -112,7 +112,10 @@ export async function processExecutionFiles( type ValidatedInputFormatField = Required> function extractInputFormatFromBlock(block: SerializedBlock): ValidatedInputFormatField[] { - const inputFormatValue = block.config?.params?.inputFormat + const metadata = block.metadata as { subBlocks?: Record } | undefined + const subBlocksValue = metadata?.subBlocks?.inputFormat?.value + const legacyValue = block.config?.params?.inputFormat + const inputFormatValue = subBlocksValue ?? legacyValue if (!Array.isArray(inputFormatValue) || inputFormatValue.length === 0) { return [] diff --git a/apps/sim/lib/mcp/workflow-tool-schema.ts b/apps/sim/lib/mcp/workflow-tool-schema.ts index ff915f76bd..45572ea52c 100644 --- a/apps/sim/lib/mcp/workflow-tool-schema.ts +++ b/apps/sim/lib/mcp/workflow-tool-schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils' import type { InputFormatField } from '@/lib/workflows/types' import type { McpToolSchema } from './types' diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index a3a9ec1663..c953a11968 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { classifyStartBlockType, StartBlockPath, diff --git a/apps/sim/lib/workflows/input-format-utils.ts b/apps/sim/lib/workflows/input-format-utils.ts deleted file mode 100644 index fec752d89f..0000000000 --- a/apps/sim/lib/workflows/input-format-utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { InputFormatField } from '@/lib/workflows/types' - -/** - * Normalizes an input format value into a list of valid fields. - * - * Filters out: - * - null or undefined values - * - Empty arrays - * - Non-array values - * - Fields without names - * - Fields with empty or whitespace-only names - * - * @param inputFormatValue - Raw input format value from subblock state - * @returns Array of validated input format fields - */ -export function normalizeInputFormatValue(inputFormatValue: unknown): InputFormatField[] { - // Handle null, undefined, and empty arrays - if ( - inputFormatValue === null || - inputFormatValue === undefined || - (Array.isArray(inputFormatValue) && inputFormatValue.length === 0) - ) { - return [] - } - - // Handle non-array values - if (!Array.isArray(inputFormatValue)) { - return [] - } - - // Filter valid fields - return inputFormatValue.filter( - (field): field is InputFormatField => - field && - typeof field === 'object' && - typeof field.name === 'string' && - field.name.trim() !== '' - ) -} diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts new file mode 100644 index 0000000000..230e7d0890 --- /dev/null +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest' +import { + extractInputFieldsFromBlocks, + normalizeInputFormatValue, +} from '@/lib/workflows/input-format' + +describe('extractInputFieldsFromBlocks', () => { + it.concurrent('returns empty array for null blocks', () => { + expect(extractInputFieldsFromBlocks(null)).toEqual([]) + }) + + it.concurrent('returns empty array for undefined blocks', () => { + expect(extractInputFieldsFromBlocks(undefined)).toEqual([]) + }) + + it.concurrent('returns empty array when no trigger block exists', () => { + const blocks = { + 'block-1': { type: 'agent', subBlocks: {} }, + 'block-2': { type: 'function', subBlocks: {} }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([]) + }) + + it.concurrent('extracts fields from start_trigger block', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [ + { name: 'query', type: 'string' }, + { name: 'count', type: 'number' }, + ], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([ + { name: 'query', type: 'string' }, + { name: 'count', type: 'number' }, + ]) + }) + + it.concurrent('extracts fields from input_trigger block', () => { + const blocks = { + 'trigger-1': { + type: 'input_trigger', + subBlocks: { + inputFormat: { + value: [{ name: 'message', type: 'string' }], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'message', type: 'string' }]) + }) + + it.concurrent('extracts fields from starter block', () => { + const blocks = { + 'trigger-1': { + type: 'starter', + subBlocks: { + inputFormat: { + value: [{ name: 'input', type: 'string' }], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'input', type: 'string' }]) + }) + + it.concurrent('defaults type to string when not provided', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [{ name: 'field1' }, { name: 'field2', type: 'number' }], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([ + { name: 'field1', type: 'string' }, + { name: 'field2', type: 'number' }, + ]) + }) + + it.concurrent('filters out fields with empty names', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [ + { name: '', type: 'string' }, + { name: 'valid', type: 'string' }, + { name: ' ' }, + ], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'valid', type: 'string' }]) + }) + + it.concurrent('filters out non-object fields', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [null, undefined, 'string', 123, { name: 'valid', type: 'string' }], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'valid', type: 'string' }]) + }) + + it.concurrent('extracts from legacy config.params.inputFormat location', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + config: { + params: { + inputFormat: [{ name: 'legacy_field', type: 'string' }], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'legacy_field', type: 'string' }]) + }) + + it.concurrent('prefers subBlocks over config.params', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [{ name: 'primary', type: 'string' }], + }, + }, + config: { + params: { + inputFormat: [{ name: 'legacy', type: 'string' }], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'primary', type: 'string' }]) + }) + + it.concurrent('returns empty array when inputFormat is not an array', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: 'not-an-array', + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([]) + }) +}) + +describe('normalizeInputFormatValue', () => { + it.concurrent('returns empty array for null', () => { + expect(normalizeInputFormatValue(null)).toEqual([]) + }) + + it.concurrent('returns empty array for undefined', () => { + expect(normalizeInputFormatValue(undefined)).toEqual([]) + }) + + it.concurrent('returns empty array for empty array', () => { + expect(normalizeInputFormatValue([])).toEqual([]) + }) + + it.concurrent('returns empty array for non-array values', () => { + expect(normalizeInputFormatValue('string')).toEqual([]) + expect(normalizeInputFormatValue(123)).toEqual([]) + expect(normalizeInputFormatValue({ name: 'test' })).toEqual([]) + }) + + it.concurrent('filters fields with valid names', () => { + const input = [ + { name: 'valid1', type: 'string' }, + { name: 'valid2', type: 'number' }, + ] + expect(normalizeInputFormatValue(input)).toEqual(input) + }) + + it.concurrent('filters out fields without names', () => { + const input = [{ type: 'string' }, { name: 'valid', type: 'string' }, { value: 'test' }] + expect(normalizeInputFormatValue(input)).toEqual([{ name: 'valid', type: 'string' }]) + }) + + it.concurrent('filters out fields with empty names', () => { + const input = [ + { name: '', type: 'string' }, + { name: ' ', type: 'string' }, + { name: 'valid', type: 'string' }, + ] + expect(normalizeInputFormatValue(input)).toEqual([{ name: 'valid', type: 'string' }]) + }) + + it.concurrent('filters out null and undefined fields', () => { + const input = [null, undefined, { name: 'valid', type: 'string' }] + expect(normalizeInputFormatValue(input)).toEqual([{ name: 'valid', type: 'string' }]) + }) + + it.concurrent('preserves all properties of valid fields', () => { + const input = [ + { + name: 'field1', + type: 'string', + label: 'Field 1', + description: 'A test field', + placeholder: 'Enter value', + required: true, + value: 'default', + }, + ] + expect(normalizeInputFormatValue(input)).toEqual(input) + }) +}) diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts new file mode 100644 index 0000000000..5e857464d4 --- /dev/null +++ b/apps/sim/lib/workflows/input-format.ts @@ -0,0 +1,111 @@ +import type { InputFormatField } from '@/lib/workflows/types' + +/** + * Simplified input field representation for workflow input mapping + */ +export interface WorkflowInputField { + name: string + type: string +} + +/** + * Extracts input fields from workflow blocks. + * Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat. + * + * @param blocks - The blocks object from workflow state + * @returns Array of input field definitions + */ +export function extractInputFieldsFromBlocks( + blocks: Record | null | undefined +): WorkflowInputField[] { + if (!blocks) return [] + + // Find trigger block + const triggerEntry = Object.entries(blocks).find(([, block]) => { + const b = block as Record + return b.type === 'start_trigger' || b.type === 'input_trigger' || b.type === 'starter' + }) + + if (!triggerEntry) return [] + + const triggerBlock = triggerEntry[1] as Record + const subBlocks = triggerBlock.subBlocks as Record | undefined + const inputFormat = subBlocks?.inputFormat?.value + + // Try primary location: subBlocks.inputFormat.value + if (Array.isArray(inputFormat)) { + return inputFormat + .filter( + (field: unknown): field is { name: string; type?: string } => + typeof field === 'object' && + field !== null && + 'name' in field && + typeof (field as { name: unknown }).name === 'string' && + (field as { name: string }).name.trim() !== '' + ) + .map((field) => ({ + name: field.name, + type: field.type || 'string', + })) + } + + // Try legacy location: config.params.inputFormat + const config = triggerBlock.config as { params?: { inputFormat?: unknown } } | undefined + const legacyFormat = config?.params?.inputFormat + + if (Array.isArray(legacyFormat)) { + return legacyFormat + .filter( + (field: unknown): field is { name: string; type?: string } => + typeof field === 'object' && + field !== null && + 'name' in field && + typeof (field as { name: unknown }).name === 'string' && + (field as { name: string }).name.trim() !== '' + ) + .map((field) => ({ + name: field.name, + type: field.type || 'string', + })) + } + + return [] +} + +/** + * Normalizes an input format value into a list of valid fields. + * + * Filters out: + * - null or undefined values + * - Empty arrays + * - Non-array values + * - Fields without names + * - Fields with empty or whitespace-only names + * + * @param inputFormatValue - Raw input format value from subblock state + * @returns Array of validated input format fields + */ +export function normalizeInputFormatValue(inputFormatValue: unknown): InputFormatField[] { + // Handle null, undefined, and empty arrays + if ( + inputFormatValue === null || + inputFormatValue === undefined || + (Array.isArray(inputFormatValue) && inputFormatValue.length === 0) + ) { + return [] + } + + // Handle non-array values + if (!Array.isArray(inputFormatValue)) { + return [] + } + + // Filter valid fields + return inputFormatValue.filter( + (field): field is InputFormatField => + field && + typeof field === 'object' && + typeof field.name === 'string' && + field.name.trim() !== '' + ) +} diff --git a/apps/sim/serializer/__test-utils__/test-workflows.ts b/apps/sim/serializer/__test-utils__/test-workflows.ts deleted file mode 100644 index eba41e15f0..0000000000 --- a/apps/sim/serializer/__test-utils__/test-workflows.ts +++ /dev/null @@ -1,662 +0,0 @@ -/** - * Test Workflows - * - * This file contains test fixtures for serializer tests, providing - * sample workflow states with different configurations. - */ -import type { Edge } from 'reactflow' -import type { BlockState, Loop } from '@/stores/workflows/workflow/types' - -/** - * Workflow State Interface - */ -export interface WorkflowStateFixture { - blocks: Record - edges: Edge[] - loops: Record -} - -/** - * Create a minimal workflow with just a starter and one block - */ -export function createMinimalWorkflowState(): WorkflowStateFixture { - const blocks: Record = { - starter: { - id: 'starter', - type: 'starter', - name: 'Starter Block', - position: { x: 0, y: 0 }, - subBlocks: { - description: { - id: 'description', - type: 'long-input', - value: 'This is the starter block', - }, - }, - outputs: {}, - enabled: true, - }, - agent1: { - id: 'agent1', - type: 'agent', - name: 'Agent Block', - position: { x: 300, y: 0 }, - subBlocks: { - provider: { - id: 'provider', - type: 'dropdown', - value: 'anthropic', - }, - model: { - id: 'model', - type: 'dropdown', - value: 'claude-3-7-sonnet-20250219', - }, - prompt: { - id: 'prompt', - type: 'long-input', - value: 'Hello, world!', - }, - tools: { - id: 'tools', - type: 'tool-input', - value: '[]', - }, - system: { - id: 'system', - type: 'long-input', - value: 'You are a helpful assistant.', - }, - responseFormat: { - id: 'responseFormat', - type: 'code', - value: null, - }, - }, - outputs: {}, - enabled: true, - }, - } - - const edges: Edge[] = [ - { - id: 'edge1', - source: 'starter', - target: 'agent1', - }, - ] - - const loops: Record = {} - - return { blocks, edges, loops } -} - -/** - * Create a workflow with condition blocks - */ -export function createConditionalWorkflowState(): WorkflowStateFixture { - const blocks: Record = { - starter: { - id: 'starter', - type: 'starter', - name: 'Starter Block', - position: { x: 0, y: 0 }, - subBlocks: { - description: { - id: 'description', - type: 'long-input', - value: 'This is the starter block', - }, - }, - outputs: {}, - enabled: true, - }, - condition1: { - id: 'condition1', - type: 'condition', - name: 'Condition Block', - position: { x: 300, y: 0 }, - subBlocks: { - condition: { - id: 'condition', - type: 'long-input', - value: 'input.value > 10', - }, - }, - outputs: {}, - enabled: true, - }, - agent1: { - id: 'agent1', - type: 'agent', - name: 'True Path Agent', - position: { x: 600, y: -100 }, - subBlocks: { - provider: { - id: 'provider', - type: 'dropdown', - value: 'anthropic', - }, - model: { - id: 'model', - type: 'dropdown', - value: 'claude-3-7-sonnet-20250219', - }, - prompt: { - id: 'prompt', - type: 'long-input', - value: 'Value is greater than 10', - }, - tools: { - id: 'tools', - type: 'tool-input', - value: '[]', - }, - system: { - id: 'system', - type: 'long-input', - value: 'You are a helpful assistant.', - }, - responseFormat: { - id: 'responseFormat', - type: 'code', - value: null, - }, - }, - outputs: {}, - enabled: true, - }, - agent2: { - id: 'agent2', - type: 'agent', - name: 'False Path Agent', - position: { x: 600, y: 100 }, - subBlocks: { - provider: { - id: 'provider', - type: 'dropdown', - value: 'anthropic', - }, - model: { - id: 'model', - type: 'dropdown', - value: 'claude-3-7-sonnet-20250219', - }, - prompt: { - id: 'prompt', - type: 'long-input', - value: 'Value is less than or equal to 10', - }, - tools: { - id: 'tools', - type: 'tool-input', - value: '[]', - }, - system: { - id: 'system', - type: 'long-input', - value: 'You are a helpful assistant.', - }, - responseFormat: { - id: 'responseFormat', - type: 'code', - value: null, - }, - }, - outputs: {}, - enabled: true, - }, - } - - const edges: Edge[] = [ - { - id: 'edge1', - source: 'starter', - target: 'condition1', - }, - { - id: 'edge2', - source: 'condition1', - target: 'agent1', - sourceHandle: 'condition-true', - }, - { - id: 'edge3', - source: 'condition1', - target: 'agent2', - sourceHandle: 'condition-false', - }, - ] - - const loops: Record = {} - - return { blocks, edges, loops } -} - -/** - * Create a workflow with a loop - */ -export function createLoopWorkflowState(): WorkflowStateFixture { - const blocks: Record = { - starter: { - id: 'starter', - type: 'starter', - name: 'Starter Block', - position: { x: 0, y: 0 }, - subBlocks: { - description: { - id: 'description', - type: 'long-input', - value: 'This is the starter block', - }, - }, - outputs: {}, - enabled: true, - }, - function1: { - id: 'function1', - type: 'function', - name: 'Function Block', - position: { x: 300, y: 0 }, - subBlocks: { - code: { - id: 'code', - type: 'code', - value: 'let counter = input.counter || 0;\ncounter++;\nreturn { counter };', - }, - language: { - id: 'language', - type: 'dropdown', - value: 'javascript', - }, - }, - outputs: {}, - enabled: true, - }, - condition1: { - id: 'condition1', - type: 'condition', - name: 'Loop Condition', - position: { x: 600, y: 0 }, - subBlocks: { - condition: { - id: 'condition', - type: 'long-input', - value: 'input.counter < 5', - }, - }, - outputs: {}, - enabled: true, - }, - agent1: { - id: 'agent1', - type: 'agent', - name: 'Loop Complete Agent', - position: { x: 900, y: 100 }, - subBlocks: { - provider: { - id: 'provider', - type: 'dropdown', - value: 'anthropic', - }, - model: { - id: 'model', - type: 'dropdown', - value: 'claude-3-7-sonnet-20250219', - }, - prompt: { - id: 'prompt', - type: 'long-input', - value: 'Loop completed after {{input.counter}} iterations', - }, - tools: { - id: 'tools', - type: 'tool-input', - value: '[]', - }, - system: { - id: 'system', - type: 'long-input', - value: 'You are a helpful assistant.', - }, - responseFormat: { - id: 'responseFormat', - type: 'code', - value: null, - }, - }, - outputs: {}, - enabled: true, - }, - } - - const edges: Edge[] = [ - { - id: 'edge1', - source: 'starter', - target: 'function1', - }, - { - id: 'edge2', - source: 'function1', - target: 'condition1', - }, - { - id: 'edge3', - source: 'condition1', - target: 'function1', - sourceHandle: 'condition-true', - }, - { - id: 'edge4', - source: 'condition1', - target: 'agent1', - sourceHandle: 'condition-false', - }, - ] - - const loops: Record = { - loop1: { - id: 'loop1', - nodes: ['function1', 'condition1'], - iterations: 10, - loopType: 'for', - }, - } - - return { blocks, edges, loops } -} - -/** - * Create a workflow with multiple block types - */ -export function createComplexWorkflowState(): WorkflowStateFixture { - const blocks: Record = { - starter: { - id: 'starter', - type: 'starter', - name: 'Starter Block', - position: { x: 0, y: 0 }, - subBlocks: { - description: { - id: 'description', - type: 'long-input', - value: 'This is the starter block', - }, - }, - outputs: {}, - enabled: true, - }, - api1: { - id: 'api1', - type: 'api', - name: 'API Request', - position: { x: 300, y: 0 }, - subBlocks: { - url: { - id: 'url', - type: 'short-input', - value: 'https://api.example.com/data', - }, - method: { - id: 'method', - type: 'dropdown', - value: 'GET', - }, - headers: { - id: 'headers', - type: 'table', - value: [ - ['Content-Type', 'application/json'], - ['Authorization', 'Bearer {{API_KEY}}'], - ], - }, - body: { - id: 'body', - type: 'long-input', - value: '', - }, - }, - outputs: {}, - enabled: true, - }, - function1: { - id: 'function1', - type: 'function', - name: 'Process Data', - position: { x: 600, y: 0 }, - subBlocks: { - code: { - id: 'code', - type: 'code', - value: 'const data = input.data;\nreturn { processed: data.map(item => item.name) };', - }, - language: { - id: 'language', - type: 'dropdown', - value: 'javascript', - }, - }, - outputs: {}, - enabled: true, - }, - agent1: { - id: 'agent1', - type: 'agent', - name: 'Summarize Data', - position: { x: 900, y: 0 }, - subBlocks: { - provider: { - id: 'provider', - type: 'dropdown', - value: 'openai', - }, - model: { - id: 'model', - type: 'dropdown', - value: 'gpt-4o', - }, - prompt: { - id: 'prompt', - type: 'long-input', - value: 'Summarize the following data:\n\n{{input.processed}}', - }, - tools: { - id: 'tools', - type: 'tool-input', - value: - '[{"type":"function","name":"calculator","description":"Perform calculations","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math expression to evaluate"}},"required":["expression"]}}]', - }, - system: { - id: 'system', - type: 'long-input', - value: 'You are a data analyst assistant.', - }, - responseFormat: { - id: 'responseFormat', - type: 'code', - value: - '{"type":"object","properties":{"summary":{"type":"string"},"keyPoints":{"type":"array","items":{"type":"string"}},"sentiment":{"type":"string","enum":["positive","neutral","negative"]}},"required":["summary","keyPoints","sentiment"]}', - }, - }, - outputs: {}, - enabled: true, - }, - } - - const edges: Edge[] = [ - { - id: 'edge1', - source: 'starter', - target: 'api1', - }, - { - id: 'edge2', - source: 'api1', - target: 'function1', - }, - { - id: 'edge3', - source: 'function1', - target: 'agent1', - }, - ] - - const loops: Record = {} - - return { blocks, edges, loops } -} - -/** - * Create a workflow with agent blocks that have custom tools - */ -export function createAgentWithToolsWorkflowState(): WorkflowStateFixture { - const blocks: Record = { - starter: { - id: 'starter', - type: 'starter', - name: 'Starter Block', - position: { x: 0, y: 0 }, - subBlocks: { - description: { - id: 'description', - type: 'long-input', - value: 'This is the starter block', - }, - }, - outputs: {}, - enabled: true, - }, - agent1: { - id: 'agent1', - type: 'agent', - name: 'Custom Tools Agent', - position: { x: 300, y: 0 }, - subBlocks: { - provider: { - id: 'provider', - type: 'dropdown', - value: 'openai', - }, - model: { - id: 'model', - type: 'dropdown', - value: 'gpt-4o', - }, - prompt: { - id: 'prompt', - type: 'long-input', - value: 'Use the tools to help answer: {{input.question}}', - }, - tools: { - id: 'tools', - type: 'tool-input', - value: - '[{"type":"custom-tool","name":"weather","description":"Get current weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}},{"type":"function","name":"calculator","description":"Calculate expression","parameters":{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}}]', - }, - system: { - id: 'system', - type: 'long-input', - value: 'You are a helpful assistant with access to tools.', - }, - responseFormat: { - id: 'responseFormat', - type: 'code', - value: null, - }, - }, - outputs: {}, - enabled: true, - }, - } - - const edges: Edge[] = [ - { - id: 'edge1', - source: 'starter', - target: 'agent1', - }, - ] - - const loops: Record = {} - - return { blocks, edges, loops } -} - -/** - * Create a workflow state with an invalid block type for error testing - */ -export function createInvalidWorkflowState(): WorkflowStateFixture { - const { blocks, edges, loops } = createMinimalWorkflowState() - - // Add an invalid block type - blocks.invalid = { - id: 'invalid', - type: 'invalid-type', - name: 'Invalid Block', - position: { x: 600, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - } - - edges.push({ - id: 'edge-invalid', - source: 'agent1', - target: 'invalid', - }) - - return { blocks, edges, loops } -} - -/** - * Create a serialized workflow with invalid metadata for error testing - */ -export function createInvalidSerializedWorkflow() { - return { - version: '1.0', - blocks: [ - { - id: 'invalid', - position: { x: 0, y: 0 }, - config: { - tool: 'invalid', - params: {}, - }, - inputs: {}, - outputs: {}, - metadata: { - id: 'non-existent-type', - }, - enabled: true, - }, - ], - connections: [], - loops: {}, - } -} - -/** - * Create a serialized workflow with missing metadata for error testing - */ -export function createMissingMetadataWorkflow() { - return { - version: '1.0', - blocks: [ - { - id: 'invalid', - position: { x: 0, y: 0 }, - config: { - tool: 'invalid', - params: {}, - }, - inputs: {}, - outputs: {}, - metadata: undefined, - enabled: true, - }, - ], - connections: [], - loops: {}, - } -} diff --git a/apps/sim/serializer/index.test.ts b/apps/sim/serializer/index.test.ts index f9f0900513..bca9c43c42 100644 --- a/apps/sim/serializer/index.test.ts +++ b/apps/sim/serializer/index.test.ts @@ -7,9 +7,7 @@ * converting between workflow state (blocks, edges, loops) and serialized format * used by the executor. */ -import { loggerMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' -import { getProviderFromModel } from '@/providers/utils' + import { createAgentWithToolsWorkflowState, createComplexWorkflowState, @@ -19,227 +17,17 @@ import { createLoopWorkflowState, createMinimalWorkflowState, createMissingMetadataWorkflow, -} from '@/serializer/__test-utils__/test-workflows' +} from '@sim/testing/factories' +import { blocksMock, loggerMock, toolsUtilsMock } from '@sim/testing/mocks' +import { describe, expect, it, vi } from 'vitest' import { Serializer } from '@/serializer/index' import type { SerializedWorkflow } from '@/serializer/types' -vi.mock('@/blocks', () => ({ - getBlock: (type: string) => { - const mockConfigs: Record = { - starter: { - name: 'Starter', - description: 'Start of the workflow', - category: 'flow', - bgColor: '#4CAF50', - tools: { - access: ['starter'], - config: { - tool: () => 'starter', - }, - }, - subBlocks: [{ id: 'description', type: 'long-input', label: 'Description' }], - inputs: {}, - }, - agent: { - name: 'Agent', - description: 'AI Agent', - category: 'ai', - bgColor: '#2196F3', - tools: { - access: ['anthropic_chat', 'openai_chat', 'google_chat'], - config: { - tool: (params: Record) => getProviderFromModel(params.model || 'gpt-4o'), - }, - }, - subBlocks: [ - { id: 'provider', type: 'dropdown', label: 'Provider' }, - { id: 'model', type: 'dropdown', label: 'Model' }, - { id: 'prompt', type: 'long-input', label: 'Prompt' }, - { id: 'tools', type: 'tool-input', label: 'Tools' }, - { id: 'system', type: 'long-input', label: 'System Message' }, - { id: 'responseFormat', type: 'code', label: 'Response Format' }, - ], - inputs: { - input: { type: 'string' }, - tools: { type: 'array' }, - }, - }, - condition: { - name: 'Condition', - description: 'Branch based on condition', - category: 'flow', - bgColor: '#FF9800', - tools: { - access: ['condition'], - config: { - tool: () => 'condition', - }, - }, - subBlocks: [{ id: 'condition', type: 'long-input', label: 'Condition' }], - inputs: { - input: { type: 'any' }, - }, - }, - function: { - name: 'Function', - description: 'Execute custom code', - category: 'code', - bgColor: '#9C27B0', - tools: { - access: ['function'], - config: { - tool: () => 'function', - }, - }, - subBlocks: [ - { id: 'code', type: 'code', label: 'Code' }, - { id: 'language', type: 'dropdown', label: 'Language' }, - ], - inputs: { - input: { type: 'any' }, - }, - }, - api: { - name: 'API', - description: 'Make API request', - category: 'data', - bgColor: '#E91E63', - tools: { - access: ['api'], - config: { - tool: () => 'api', - }, - }, - subBlocks: [ - { id: 'url', type: 'short-input', label: 'URL' }, - { id: 'method', type: 'dropdown', label: 'Method' }, - { id: 'headers', type: 'table', label: 'Headers' }, - { id: 'body', type: 'long-input', label: 'Body' }, - ], - inputs: {}, - }, - jina: { - name: 'Jina', - description: 'Convert website content into text', - category: 'tools', - bgColor: '#333333', - tools: { - access: ['jina_read_url'], - config: { - tool: () => 'jina_read_url', - }, - }, - subBlocks: [ - { id: 'url', type: 'short-input', title: 'URL', required: true }, - { id: 'apiKey', type: 'short-input', title: 'API Key', required: true }, - ], - inputs: { - url: { type: 'string' }, - apiKey: { type: 'string' }, - }, - }, - reddit: { - name: 'Reddit', - description: 'Access Reddit data and content', - category: 'tools', - bgColor: '#FF5700', - tools: { - access: ['reddit_get_posts', 'reddit_get_comments'], - config: { - tool: () => 'reddit_get_posts', - }, - }, - subBlocks: [ - { id: 'operation', type: 'dropdown', title: 'Operation', required: true }, - { id: 'credential', type: 'oauth-input', title: 'Reddit Account', required: true }, - { id: 'subreddit', type: 'short-input', title: 'Subreddit', required: true }, - ], - inputs: { - operation: { type: 'string' }, - credential: { type: 'string' }, - subreddit: { type: 'string' }, - }, - }, - slack: { - name: 'Slack', - description: 'Send messages to Slack', - category: 'tools', - bgColor: '#611f69', - tools: { - access: ['slack_send_message'], - config: { - tool: () => 'slack_send_message', - }, - }, - subBlocks: [ - { id: 'channel', type: 'dropdown', title: 'Channel', mode: 'basic' }, - { id: 'manualChannel', type: 'short-input', title: 'Channel ID', mode: 'advanced' }, - { id: 'text', type: 'long-input', title: 'Message' }, - { id: 'username', type: 'short-input', title: 'Username', mode: 'both' }, - ], - inputs: { - channel: { type: 'string' }, - manualChannel: { type: 'string' }, - text: { type: 'string' }, - username: { type: 'string' }, - }, - }, - agentWithMemories: { - name: 'Agent with Memories', - description: 'AI Agent with memory support', - category: 'ai', - bgColor: '#2196F3', - tools: { - access: ['anthropic_chat'], - config: { - tool: () => 'anthropic_chat', - }, - }, - subBlocks: [ - { id: 'systemPrompt', type: 'long-input', title: 'System Prompt' }, - { id: 'userPrompt', type: 'long-input', title: 'User Prompt' }, - { id: 'memories', type: 'short-input', title: 'Memories', mode: 'advanced' }, - { id: 'model', type: 'dropdown', title: 'Model' }, - ], - inputs: { - systemPrompt: { type: 'string' }, - userPrompt: { type: 'string' }, - memories: { type: 'array' }, - model: { type: 'string' }, - }, - }, - } - - return mockConfigs[type] || null - }, -})) - -vi.mock('@/tools/utils', () => ({ - getTool: (toolId: string) => { - const mockTools: Record = { - jina_read_url: { - params: { - url: { visibility: 'user-or-llm', required: true }, - apiKey: { visibility: 'user-only', required: true }, - }, - }, - reddit_get_posts: { - params: { - subreddit: { visibility: 'user-or-llm', required: true }, - credential: { visibility: 'user-only', required: true }, - }, - }, - } - return mockTools[toolId] || null - }, -})) - +vi.mock('@/blocks', () => blocksMock) +vi.mock('@/tools/utils', () => toolsUtilsMock) vi.mock('@sim/logger', () => loggerMock) describe('Serializer', () => { - /** - * Serialization tests - */ describe('serializeWorkflow', () => { it.concurrent('should serialize a minimal workflow correctly', () => { const { blocks, edges, loops } = createMinimalWorkflowState() @@ -361,7 +149,6 @@ describe('Serializer', () => { const toolsParam = agentBlock?.config.params.tools expect(toolsParam).toBeDefined() - // Parse tools to verify content const tools = JSON.parse(toolsParam as string) expect(tools).toHaveLength(2) @@ -384,9 +171,6 @@ describe('Serializer', () => { }) }) - /** - * Deserialization tests - */ describe('deserializeWorkflow', () => { it.concurrent('should deserialize a serialized workflow correctly', () => { const { blocks, edges, loops } = createMinimalWorkflowState() @@ -465,9 +249,6 @@ describe('Serializer', () => { }) }) - /** - * End-to-end serialization/deserialization tests - */ describe('round-trip serialization', () => { it.concurrent('should preserve all data through serialization and deserialization', () => { const { blocks, edges, loops } = createComplexWorkflowState() diff --git a/apps/sim/serializer/tests/dual-validation.test.ts b/apps/sim/serializer/tests/dual-validation.test.ts index bfb3496229..ac9d4dc2ec 100644 --- a/apps/sim/serializer/tests/dual-validation.test.ts +++ b/apps/sim/serializer/tests/dual-validation.test.ts @@ -7,52 +7,11 @@ * 1. Early validation (serialization) - user-only required fields * 2. Late validation (tool execution) - user-or-llm required fields */ +import { blocksMock } from '@sim/testing/mocks' import { describe, expect, it, vi } from 'vitest' import { Serializer } from '@/serializer/index' -vi.mock('@/blocks', () => ({ - getBlock: (type: string) => { - const mockConfigs: Record = { - jina: { - name: 'Jina', - description: 'Convert website content into text', - category: 'tools', - bgColor: '#333333', - tools: { - access: ['jina_read_url'], - }, - subBlocks: [ - { id: 'url', type: 'short-input', title: 'URL', required: true }, - { id: 'apiKey', type: 'short-input', title: 'API Key', required: true }, - ], - inputs: { - url: { type: 'string' }, - apiKey: { type: 'string' }, - }, - }, - reddit: { - name: 'Reddit', - description: 'Access Reddit data', - category: 'tools', - bgColor: '#FF5700', - tools: { - access: ['reddit_get_posts'], - }, - subBlocks: [ - { id: 'operation', type: 'dropdown', title: 'Operation', required: true }, - { id: 'credential', type: 'oauth-input', title: 'Reddit Account', required: true }, - { id: 'subreddit', type: 'short-input', title: 'Subreddit', required: true }, - ], - inputs: { - operation: { type: 'string' }, - credential: { type: 'string' }, - subreddit: { type: 'string' }, - }, - }, - } - return mockConfigs[type] || null - }, -})) +vi.mock('@/blocks', () => blocksMock) /** * Validates required parameters after user and LLM parameter merge. diff --git a/apps/sim/serializer/tests/serializer.extended.test.ts b/apps/sim/serializer/tests/serializer.extended.test.ts index 0286ad4777..d66b40b242 100644 --- a/apps/sim/serializer/tests/serializer.extended.test.ts +++ b/apps/sim/serializer/tests/serializer.extended.test.ts @@ -4,7 +4,6 @@ * Extended Serializer Tests * * These tests cover edge cases, complex scenarios, and gaps in coverage - * for the Serializer class using @sim/testing helpers. */ import { @@ -13,9 +12,9 @@ import { createLoopWorkflow, createParallelWorkflow, createStarterBlock, - loggerMock, WorkflowBuilder, } from '@sim/testing' +import { loggerMock, toolsUtilsMock } from '@sim/testing/mocks' import { describe, expect, it, vi } from 'vitest' import { Serializer, WorkflowValidationError } from '@/serializer/index' import type { SerializedWorkflow } from '@/serializer/types' @@ -29,195 +28,207 @@ function asAppBlocks(blocks: T): Record { return blocks as unknown as Record } -vi.mock('@/blocks', () => ({ - getBlock: (type: string) => { - const mockConfigs: Record = { - starter: { - name: 'Starter', - description: 'Start of the workflow', - category: 'flow', - bgColor: '#4CAF50', - tools: { - access: ['starter'], - config: { tool: () => 'starter' }, - }, - subBlocks: [ - { id: 'description', type: 'long-input', label: 'Description' }, - { id: 'inputFormat', type: 'table', label: 'Input Format' }, - ], - inputs: {}, +/** + * Hoisted mock setup - vi.mock is hoisted, so we need to hoist the config too. + */ +const { mockBlockConfigs, createMockGetBlock, slackWithCanonicalParam } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockBlockConfigs: Record = { + starter: { + name: 'Starter', + description: 'Start of the workflow', + category: 'flow', + bgColor: '#4CAF50', + tools: { + access: ['starter'], + config: { tool: () => 'starter' }, }, - agent: { - name: 'Agent', - description: 'AI Agent', - category: 'ai', - bgColor: '#2196F3', - tools: { - access: ['anthropic_chat', 'openai_chat'], - config: { - tool: (params: Record) => { - const model = params.model || 'gpt-4o' - if (model.includes('claude')) return 'anthropic' - if (model.includes('gpt') || model.includes('o1')) return 'openai' - if (model.includes('gemini')) return 'google' - return 'openai' - }, + subBlocks: [ + { id: 'description', type: 'long-input', label: 'Description' }, + { id: 'inputFormat', type: 'table', label: 'Input Format' }, + ], + inputs: {}, + }, + agent: { + name: 'Agent', + description: 'AI Agent', + category: 'ai', + bgColor: '#2196F3', + tools: { + access: ['anthropic_chat', 'openai_chat'], + config: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool: (params: Record) => { + const model = params.model || 'gpt-4o' + if (model.includes('claude')) return 'anthropic' + if (model.includes('gpt') || model.includes('o1')) return 'openai' + if (model.includes('gemini')) return 'google' + return 'openai' }, }, - subBlocks: [ - { id: 'provider', type: 'dropdown', label: 'Provider' }, - { id: 'model', type: 'dropdown', label: 'Model' }, - { id: 'prompt', type: 'long-input', label: 'Prompt' }, - { id: 'system', type: 'long-input', label: 'System Message' }, - { id: 'tools', type: 'tool-input', label: 'Tools' }, - { id: 'responseFormat', type: 'code', label: 'Response Format' }, - { id: 'messages', type: 'messages-input', label: 'Messages' }, - ], - inputs: { - input: { type: 'string' }, - tools: { type: 'array' }, - }, }, - function: { - name: 'Function', - description: 'Execute custom code', - category: 'code', - bgColor: '#9C27B0', - tools: { - access: ['function'], - config: { tool: () => 'function' }, - }, - subBlocks: [ - { id: 'code', type: 'code', label: 'Code' }, - { id: 'language', type: 'dropdown', label: 'Language' }, - ], - inputs: { input: { type: 'any' } }, + subBlocks: [ + { id: 'provider', type: 'dropdown', label: 'Provider' }, + { id: 'model', type: 'dropdown', label: 'Model' }, + { id: 'prompt', type: 'long-input', label: 'Prompt' }, + { id: 'system', type: 'long-input', label: 'System Message' }, + { id: 'tools', type: 'tool-input', label: 'Tools' }, + { id: 'responseFormat', type: 'code', label: 'Response Format' }, + { id: 'messages', type: 'messages-input', label: 'Messages' }, + ], + inputs: { + input: { type: 'string' }, + tools: { type: 'array' }, }, - condition: { - name: 'Condition', - description: 'Branch based on condition', - category: 'flow', - bgColor: '#FF9800', - tools: { - access: ['condition'], - config: { tool: () => 'condition' }, - }, - subBlocks: [{ id: 'condition', type: 'long-input', label: 'Condition' }], - inputs: { input: { type: 'any' } }, + }, + function: { + name: 'Function', + description: 'Execute custom code', + category: 'code', + bgColor: '#9C27B0', + tools: { + access: ['function'], + config: { tool: () => 'function' }, }, - api: { - name: 'API', - description: 'Make API request', - category: 'data', - bgColor: '#E91E63', - tools: { - access: ['api'], - config: { tool: () => 'api' }, - }, - subBlocks: [ - { id: 'url', type: 'short-input', label: 'URL' }, - { id: 'method', type: 'dropdown', label: 'Method' }, - { id: 'headers', type: 'table', label: 'Headers' }, - { id: 'body', type: 'long-input', label: 'Body' }, - ], - inputs: {}, + subBlocks: [ + { id: 'code', type: 'code', label: 'Code' }, + { id: 'language', type: 'dropdown', label: 'Language' }, + ], + inputs: { input: { type: 'any' } }, + }, + condition: { + name: 'Condition', + description: 'Branch based on condition', + category: 'flow', + bgColor: '#FF9800', + tools: { + access: ['condition'], + config: { tool: () => 'condition' }, }, - webhook: { - name: 'Webhook', - description: 'Webhook trigger', - category: 'triggers', - bgColor: '#4CAF50', - tools: { - access: ['webhook'], - config: { tool: () => 'webhook' }, - }, - subBlocks: [{ id: 'path', type: 'short-input', label: 'Path' }], - inputs: {}, + subBlocks: [{ id: 'condition', type: 'long-input', label: 'Condition' }], + inputs: { input: { type: 'any' } }, + }, + api: { + name: 'API', + description: 'Make API request', + category: 'data', + bgColor: '#E91E63', + tools: { + access: ['api'], + config: { tool: () => 'api' }, }, - slack: { - name: 'Slack', - description: 'Send messages to Slack', - category: 'tools', - bgColor: '#611f69', - tools: { - access: ['slack_send_message'], - config: { tool: () => 'slack_send_message' }, - }, - subBlocks: [ - { id: 'channel', type: 'dropdown', label: 'Channel', mode: 'basic' }, - { - id: 'manualChannel', - type: 'short-input', - label: 'Channel ID', - mode: 'advanced', - canonicalParamId: 'targetChannel', - }, - { - id: 'channelSelector', - type: 'dropdown', - label: 'Channel Selector', - mode: 'basic', - canonicalParamId: 'targetChannel', - }, - { id: 'text', type: 'long-input', label: 'Message' }, - { id: 'username', type: 'short-input', label: 'Username', mode: 'both' }, - ], - inputs: { text: { type: 'string' } }, + subBlocks: [ + { id: 'url', type: 'short-input', label: 'URL' }, + { id: 'method', type: 'dropdown', label: 'Method' }, + { id: 'headers', type: 'table', label: 'Headers' }, + { id: 'body', type: 'long-input', label: 'Body' }, + ], + inputs: {}, + }, + webhook: { + name: 'Webhook', + description: 'Webhook trigger', + category: 'triggers', + bgColor: '#4CAF50', + tools: { + access: ['webhook'], + config: { tool: () => 'webhook' }, }, - conditional_block: { - name: 'Conditional Block', - description: 'Block with conditional fields', - category: 'tools', - bgColor: '#FF5700', - tools: { - access: ['conditional_tool'], - config: { tool: () => 'conditional_tool' }, + subBlocks: [{ id: 'path', type: 'short-input', label: 'Path' }], + inputs: {}, + }, + slack: { + name: 'Slack', + description: 'Send messages to Slack', + category: 'tools', + bgColor: '#611f69', + tools: { + access: ['slack_send_message'], + config: { tool: () => 'slack_send_message' }, + }, + subBlocks: [ + { id: 'channel', type: 'dropdown', label: 'Channel', mode: 'basic' }, + { + id: 'manualChannel', + type: 'short-input', + label: 'Channel ID', + mode: 'advanced', + canonicalParamId: 'targetChannel', }, - subBlocks: [ - { id: 'mode', type: 'dropdown', label: 'Mode' }, - { - id: 'optionA', - type: 'short-input', - label: 'Option A', - condition: { field: 'mode', value: 'a' }, - }, - { - id: 'optionB', - type: 'short-input', - label: 'Option B', - condition: { field: 'mode', value: 'b' }, - }, - { - id: 'notModeC', - type: 'short-input', - label: 'Not Mode C', - condition: { field: 'mode', value: 'c', not: true }, - }, - { - id: 'complexCondition', - type: 'short-input', - label: 'Complex', - condition: { field: 'mode', value: 'a', and: { field: 'optionA', value: 'special' } }, - }, - { - id: 'arrayCondition', - type: 'short-input', - label: 'Array Condition', - condition: { field: 'mode', value: ['a', 'b'] }, - }, - ], - inputs: {}, + { + id: 'channelSelector', + type: 'dropdown', + label: 'Channel Selector', + mode: 'basic', + canonicalParamId: 'targetChannel', + }, + { id: 'text', type: 'long-input', label: 'Message' }, + { id: 'username', type: 'short-input', label: 'Username', mode: 'both' }, + ], + inputs: { text: { type: 'string' } }, + }, + conditional_block: { + name: 'Conditional Block', + description: 'Block with conditional fields', + category: 'tools', + bgColor: '#FF5700', + tools: { + access: ['conditional_tool'], + config: { tool: () => 'conditional_tool' }, }, - } + subBlocks: [ + { id: 'mode', type: 'dropdown', label: 'Mode' }, + { + id: 'optionA', + type: 'short-input', + label: 'Option A', + condition: { field: 'mode', value: 'a' }, + }, + { + id: 'optionB', + type: 'short-input', + label: 'Option B', + condition: { field: 'mode', value: 'b' }, + }, + { + id: 'notModeC', + type: 'short-input', + label: 'Not Mode C', + condition: { field: 'mode', value: 'c', not: true }, + }, + { + id: 'complexCondition', + type: 'short-input', + label: 'Complex', + condition: { field: 'mode', value: 'a', and: { field: 'optionA', value: 'special' } }, + }, + { + id: 'arrayCondition', + type: 'short-input', + label: 'Array Condition', + condition: { field: 'mode', value: ['a', 'b'] }, + }, + ], + inputs: {}, + }, + } - return mockConfigs[type] || null - }, -})) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createMockGetBlock = (extraConfigs: Record = {}) => { + const configs = { ...mockBlockConfigs, ...extraConfigs } + return (type: string) => configs[type] || null + } -vi.mock('@/tools/utils', () => ({ - getTool: () => null, -})) + const slackWithCanonicalParam = mockBlockConfigs.slack + + return { mockBlockConfigs, createMockGetBlock, slackWithCanonicalParam } +}) +vi.mock('@/blocks', () => ({ + getBlock: createMockGetBlock(), + getAllBlocks: () => Object.values(mockBlockConfigs), +})) +vi.mock('@/tools/utils', () => toolsUtilsMock) vi.mock('@sim/logger', () => loggerMock) describe('Serializer Extended Tests', () => { diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index e4b0d816f4..c5ab2147c3 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -6,16 +6,18 @@ * This file contains unit tests for the Function Execute tool, * which runs JavaScript code in a secure sandbox. */ + +import { ToolTester } from '@sim/testing/builders' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' -import { ToolTester } from '@/tools/__test-utils__/test-tools' import { functionExecuteTool } from '@/tools/function/execute' describe('Function Execute Tool', () => { - let tester: ToolTester + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let tester: ToolTester beforeEach(() => { - tester = new ToolTester(functionExecuteTool) + tester = new ToolTester(functionExecuteTool as any) process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' }) @@ -338,7 +340,7 @@ describe('Function Execute Tool', () => { code: '', }) - const body = tester.getRequestBody({ code: '' }) + const body = tester.getRequestBody({ code: '' }) as { code: string } expect(body.code).toBe('') }) @@ -346,7 +348,7 @@ describe('Function Execute Tool', () => { const body = tester.getRequestBody({ code: 'return 42', timeout: 1, // 1ms timeout - }) + }) as { timeout: number } expect(body.timeout).toBe(1) }) diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index 089c8e1aa5..d338a030cf 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -6,19 +6,21 @@ * This file contains unit tests for the HTTP Request tool, which is used * to make HTTP requests to external APIs and services. */ + +import { ToolTester } from '@sim/testing/builders' +import { mockHttpResponses } from '@sim/testing/factories' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mockHttpResponses } from '@/tools/__test-utils__/mock-data' -import { ToolTester } from '@/tools/__test-utils__/test-tools' import { requestTool } from '@/tools/http/request' process.env.VITEST = 'true' describe('HTTP Request Tool', () => { - let tester: ToolTester + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let tester: ToolTester beforeEach(() => { - tester = new ToolTester(requestTool) - process.env.NEXT_PUBLIC_APP_URL = 'https://app.simstudio.dev' + tester = new ToolTester(requestTool as any) + process.env.NEXT_PUBLIC_APP_URL = 'https://sim.ai' }) afterEach(() => { @@ -122,7 +124,7 @@ describe('HTTP Request Tool', () => { Object.defineProperty(global, 'window', { value: { location: { - origin: 'https://app.simstudio.dev', + origin: 'https://sim.ai', }, }, writable: true, @@ -136,7 +138,7 @@ describe('HTTP Request Tool', () => { }) const fetchCall = (global.fetch as any).mock.calls[0] - expect(fetchCall[1].headers.Referer).toBe('https://app.simstudio.dev') + expect(fetchCall[1].headers.Referer).toBe('https://sim.ai') global.window = originalWindow }) @@ -195,7 +197,7 @@ describe('HTTP Request Tool', () => { Object.defineProperty(global, 'window', { value: { location: { - origin: 'https://app.simstudio.dev', + origin: 'https://sim.ai', }, }, writable: true, @@ -210,7 +212,7 @@ describe('HTTP Request Tool', () => { const headers = fetchCall[1].headers expect(headers.Host).toBe('api.example.com') - expect(headers.Referer).toBe('https://app.simstudio.dev') + expect(headers.Referer).toBe('https://sim.ai') expect(headers['User-Agent']).toContain('Mozilla') expect(headers.Accept).toBe('*/*') expect(headers['Accept-Encoding']).toContain('gzip') @@ -398,7 +400,7 @@ describe('HTTP Request Tool', () => { Object.defineProperty(global, 'window', { value: { location: { - origin: 'https://app.simstudio.dev', + origin: 'https://sim.ai', }, }, writable: true, @@ -420,7 +422,7 @@ describe('HTTP Request Tool', () => { expect(headers['Sec-Ch-Ua']).toMatch(/Chromium.*Not-A\.Brand/) expect(headers['Sec-Ch-Ua-Mobile']).toBe('?0') expect(headers['Sec-Ch-Ua-Platform']).toBe('"macOS"') - expect(headers.Referer).toBe('https://app.simstudio.dev') + expect(headers.Referer).toBe('https://sim.ai') expect(headers.Host).toBe('api.example.com') global.window = originalWindow @@ -455,7 +457,7 @@ describe('HTTP Request Tool', () => { Object.defineProperty(global, 'window', { value: { location: { - origin: 'https://app.simstudio.dev', + origin: 'https://sim.ai', }, }, writable: true, diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index e4a92e67ed..b50329423f 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import type { ParameterVisibility, ToolConfig } from '@/tools/types' import { getTool } from '@/tools/utils' @@ -502,34 +503,7 @@ async function fetchWorkflowInputFields( } const { data } = await response.json() - if (!data?.state?.blocks) { - return [] - } - - const blocks = data.state.blocks as Record - const triggerEntry = Object.entries(blocks).find( - ([, block]) => - block.type === 'start_trigger' || block.type === 'input_trigger' || block.type === 'starter' - ) - - if (!triggerEntry) { - return [] - } - - const triggerBlock = triggerEntry[1] - const inputFormat = triggerBlock.subBlocks?.inputFormat?.value - - let fields: Array<{ name: string; type: string }> = [] - - if (Array.isArray(inputFormat)) { - fields = inputFormat - .filter((field: any) => field.name && typeof field.name === 'string') - .map((field: any) => ({ - name: field.name, - type: field.type || 'string', - })) - } - + const fields = extractInputFieldsFromBlocks(data?.state?.blocks) workflowInputFieldsCache.set(workflowId, { fields, timestamp: now }) return fields diff --git a/packages/testing/package.json b/packages/testing/package.json index 0ce467cfc0..ca88ddee07 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -25,6 +25,10 @@ "types": "./src/mocks/index.ts", "default": "./src/mocks/index.ts" }, + "./mocks/executor": { + "types": "./src/mocks/executor.mock.ts", + "default": "./src/mocks/executor.mock.ts" + }, "./assertions": { "types": "./src/assertions/index.ts", "default": "./src/assertions/index.ts" diff --git a/packages/testing/src/builders/index.ts b/packages/testing/src/builders/index.ts index d8272bdc94..b15e9a6ffd 100644 --- a/packages/testing/src/builders/index.ts +++ b/packages/testing/src/builders/index.ts @@ -18,4 +18,11 @@ */ export { ExecutionContextBuilder } from './execution.builder' +export { + createErrorFetch, + createToolMockFetch, + type TestToolConfig, + type ToolResponse, + ToolTester, +} from './tool-tester.builder' export { WorkflowBuilder } from './workflow.builder' diff --git a/apps/sim/tools/__test-utils__/test-tools.ts b/packages/testing/src/builders/tool-tester.builder.ts similarity index 64% rename from apps/sim/tools/__test-utils__/test-tools.ts rename to packages/testing/src/builders/tool-tester.builder.ts index f73fd04509..ba165e9683 100644 --- a/apps/sim/tools/__test-utils__/test-tools.ts +++ b/packages/testing/src/builders/tool-tester.builder.ts @@ -1,12 +1,11 @@ /** * Test Tools Utilities * - * This file contains utility functions and classes for testing tools + * Utility functions and classes for testing tools * in a controlled environment without external dependencies. */ -import { createMockFetch as createBaseMockFetch, type MockFetchResponse } from '@sim/testing' import { type Mock, vi } from 'vitest' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createMockFetch as createBaseMockFetch, type MockFetchResponse } from '../mocks/fetch.mock' /** * Type that combines Mock with fetch properties including Next.js preconnect. @@ -15,6 +14,30 @@ type MockFetch = Mock & { preconnect: Mock } +/** + * Tool configuration interface (simplified for testing). + * Compatible with actual tool configs from @/tools. + */ +export interface TestToolConfig

{ + id: string + request: { + url: string | ((params: P) => string) + method: string | ((params: P) => string) + headers: (params: P) => Record + body?: (params: P) => unknown + } + transformResponse?: (response: Response, params: P) => Promise +} + +/** + * Tool response interface + */ +export interface ToolResponse { + success: boolean + output: Record + error?: string +} + /** * Create standard mock headers for HTTP testing. */ @@ -26,7 +49,7 @@ const createMockHeaders = (customHeaders: Record = {}) => { 'Accept-Encoding': 'gzip, deflate, br', 'Cache-Control': 'no-cache', Connection: 'keep-alive', - Referer: 'https://www.simstudio.dev', + Referer: 'https://www.sim.ai', 'Sec-Ch-Ua': 'Chromium;v=91, Not-A.Brand;v=99', 'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Platform': '"macOS"', @@ -38,8 +61,8 @@ const createMockHeaders = (customHeaders: Record = {}) => { * Creates a mock fetch function with Next.js preconnect support. * Wraps the @sim/testing createMockFetch with tool-specific additions. */ -export function createMockFetch( - responseData: any, +export function createToolMockFetch( + responseData: unknown, options: { ok?: boolean; status?: number; headers?: Record } = {} ) { const { ok = true, status = 200, headers = { 'Content-Type': 'application/json' } } = options @@ -53,7 +76,7 @@ export function createMockFetch( } const baseMockFetch = createBaseMockFetch(mockFetchConfig) - ;(baseMockFetch as any).preconnect = vi.fn() + ;(baseMockFetch as MockFetch).preconnect = vi.fn() return baseMockFetch as MockFetch } @@ -63,11 +86,11 @@ export function createMockFetch( */ export function createErrorFetch(errorMessage: string, status = 400) { const error = new Error(errorMessage) - ;(error as any).status = status + ;(error as Error & { status: number }).status = status if (status < 0) { const mockFn = vi.fn().mockRejectedValue(error) - ;(mockFn as any).preconnect = vi.fn() + ;(mockFn as MockFetch).preconnect = vi.fn() return mockFn as MockFetch } @@ -79,7 +102,7 @@ export function createErrorFetch(errorMessage: string, status = 400) { } const baseMockFetch = createBaseMockFetch(mockFetchConfig) - ;(baseMockFetch as any).preconnect = vi.fn() + ;(baseMockFetch as MockFetch).preconnect = vi.fn() return baseMockFetch as MockFetch } @@ -87,14 +110,15 @@ export function createErrorFetch(errorMessage: string, status = 400) { /** * Helper class for testing tools with controllable mock responses */ -export class ToolTester

{ - tool: ToolConfig +export class ToolTester

{ + tool: TestToolConfig private mockFetch: MockFetch private originalFetch: typeof fetch - private mockResponse: any + private mockResponse: unknown private mockResponseOptions: { ok: boolean; status: number; headers: Record } + private error: Error | null = null - constructor(tool: ToolConfig) { + constructor(tool: TestToolConfig) { this.tool = tool this.mockResponse = { success: true, output: {} } this.mockResponseOptions = { @@ -102,7 +126,7 @@ export class ToolTester

{ status: 200, headers: { 'content-type': 'application/json' }, } - this.mockFetch = createMockFetch(this.mockResponse, this.mockResponseOptions) + this.mockFetch = createToolMockFetch(this.mockResponse, this.mockResponseOptions) this.originalFetch = global.fetch } @@ -110,7 +134,7 @@ export class ToolTester

{ * Setup mock responses for this tool */ setup( - response: any, + response: unknown, options: { ok?: boolean; status?: number; headers?: Record } = {} ) { this.mockResponse = response @@ -119,7 +143,7 @@ export class ToolTester

{ status: options.status ?? 200, headers: options.headers ?? { 'content-type': 'application/json' }, } - this.mockFetch = createMockFetch(this.mockResponse, this.mockResponseOptions) + this.mockFetch = createToolMockFetch(this.mockResponse, this.mockResponseOptions) global.fetch = Object.assign(this.mockFetch, { preconnect: vi.fn() }) as typeof fetch return this } @@ -131,15 +155,11 @@ export class ToolTester

{ this.mockFetch = createErrorFetch(errorMessage, status) global.fetch = Object.assign(this.mockFetch, { preconnect: vi.fn() }) as typeof fetch - // Create an error object for direct error handling this.error = new Error(errorMessage) - this.error.message = errorMessage - this.error.status = status + ;(this.error as Error & { status: number }).status = status - // For network errors (negative status), we'll need the error object - // For HTTP errors (positive status), the response will be used if (status > 0) { - this.error.response = { + ;(this.error as Error & { response: unknown }).response = { ok: false, status, statusText: errorMessage, @@ -150,27 +170,27 @@ export class ToolTester

{ return this } - // Store the error for direct error handling - private error: any = null - /** * Execute the tool with provided parameters */ - async execute(params: P, skipProxy = true): Promise { + async execute(params: P, _skipProxy = true): Promise { const url = typeof this.tool.request.url === 'function' ? this.tool.request.url(params) : this.tool.request.url try { - // For HTTP requests, use the method specified in params if available - const method = - this.tool.id === 'http_request' && (params as any)?.method - ? (params as any).method - : this.tool.request.method + let method: string + if (this.tool.id === 'http_request' && (params as Record)?.method) { + method = (params as Record).method as string + } else if (typeof this.tool.request.method === 'function') { + method = this.tool.request.method(params) + } else { + method = this.tool.request.method + } const response = await this.mockFetch(url, { - method: method, + method, headers: this.tool.request.headers(params), body: this.tool.request.body ? (() => { @@ -187,33 +207,31 @@ export class ToolTester

{ }) if (!response.ok) { - // Extract error message directly from response const data = await response.json().catch(() => ({})) + let errorMessage = + (data as Record).error || + (data as Record).message || + response.statusText || + 'Request failed' - // Extract meaningful error message from the response - let errorMessage = data.error || data.message || response.statusText || 'Request failed' - - // Add specific error messages for common status codes if (response.status === 404) { - errorMessage = data.error || data.message || 'Not Found' + errorMessage = + (data as Record).error || + (data as Record).message || + 'Not Found' } else if (response.status === 401) { - errorMessage = data.error || data.message || 'Unauthorized' + errorMessage = + (data as Record).error || + (data as Record).message || + 'Unauthorized' } - return { - success: false, - output: {}, - error: errorMessage, - } + return { success: false, output: {}, error: errorMessage } } - // Continue with successful response handling return await this.handleSuccessfulResponse(response, params) } catch (error) { - // Handle thrown errors (network errors, etc.) const errorToUse = this.error || error - - // Extract error message directly from error object let errorMessage = 'Network error' if (errorToUse instanceof Error) { @@ -221,31 +239,21 @@ export class ToolTester

{ } else if (typeof errorToUse === 'string') { errorMessage = errorToUse } else if (errorToUse && typeof errorToUse === 'object') { - // Try to extract error message from error object structure errorMessage = - errorToUse.error || errorToUse.message || errorToUse.statusText || 'Network error' + (errorToUse as Record).error || + (errorToUse as Record).message || + (errorToUse as Record).statusText || + 'Network error' } - return { - success: false, - output: {}, - error: errorMessage, - } + return { success: false, output: {}, error: errorMessage } } } - /** - * Handle a successful response - */ private async handleSuccessfulResponse(response: Response, params: P): Promise { - // Special case for HTTP request tool in test environment if (this.tool.id === 'http_request') { - // For the GET request test that checks specific format - // Use the mockHttpResponses.simple format directly - if ( - (params as any).url === 'https://api.example.com/data' && - (params as any).method === 'GET' - ) { + const httpParams = params as Record + if (httpParams.url === 'https://api.example.com/data' && httpParams.method === 'GET') { return { success: true, output: { @@ -260,32 +268,20 @@ export class ToolTester

{ if (this.tool.transformResponse) { const result = await this.tool.transformResponse(response, params) - // Ensure we're returning a ToolResponse by checking if it has the required structure if ( typeof result === 'object' && result !== null && 'success' in result && 'output' in result ) { - // If it looks like a ToolResponse, ensure success is set to true and return it - return { - ...result, - success: true, - } as ToolResponse + return { ...(result as ToolResponse), success: true } } - // If it's not a ToolResponse (e.g., it's some other type R), wrap it - return { - success: true, - output: result as any, - } + return { success: true, output: result as Record } } const data = await response.json() - return { - success: true, - output: data, - } + return { success: true, output: data as Record } } /** @@ -306,15 +302,10 @@ export class ToolTester

{ * Get URL that would be used for a request */ getRequestUrl(params: P): string { - // Special case for HTTP request tool tests if (this.tool.id === 'http_request' && params) { - // Cast to any here since this is a special test case for HTTP requests - // which we know will have these properties - const httpParams = params as any - + const httpParams = params as Record let urlStr = httpParams.url as string - // Handle path parameters if (httpParams.pathParams) { const pathParams = httpParams.pathParams as Record Object.entries(pathParams).forEach(([key, value]) => { @@ -324,7 +315,6 @@ export class ToolTester

{ const url = new URL(urlStr) - // Add query parameters if they exist if (httpParams.params) { const queryParams = httpParams.params as Array<{ Key: string; Value: string }> queryParams.forEach((param) => { @@ -335,13 +325,11 @@ export class ToolTester

{ return url.toString() } - // For other tools, use the regular pattern const url = typeof this.tool.request.url === 'function' ? this.tool.request.url(params) : this.tool.request.url - // For testing purposes, return the decoded URL to make tests easier to write return decodeURIComponent(url) } @@ -349,11 +337,9 @@ export class ToolTester

{ * Get headers that would be used for a request */ getRequestHeaders(params: P): Record { - // Special case for HTTP request tool tests with headers parameter if (this.tool.id === 'http_request' && params) { - const httpParams = params as any + const httpParams = params as Record - // For the first test case that expects empty headers if ( httpParams.url === 'https://api.example.com' && httpParams.method === 'GET' && @@ -363,55 +349,54 @@ export class ToolTester

{ return {} } - // For the custom headers test case - need to return exactly this format if ( httpParams.url === 'https://api.example.com' && httpParams.method === 'GET' && httpParams.headers && - httpParams.headers.length === 2 && - httpParams.headers[0]?.Key === 'Authorization' + (httpParams.headers as Array<{ Key: string; Value: string }>).length === 2 && + (httpParams.headers as Array<{ Key: string; Value: string }>)[0]?.Key === 'Authorization' ) { return { - Authorization: httpParams.headers[0].Value, - Accept: httpParams.headers[1].Value, + Authorization: (httpParams.headers as Array<{ Key: string; Value: string }>)[0].Value, + Accept: (httpParams.headers as Array<{ Key: string; Value: string }>)[1].Value, } } - // For the POST with body test case that expects only Content-Type header if ( httpParams.url === 'https://api.example.com' && httpParams.method === 'POST' && httpParams.body && !httpParams.headers ) { - return { - 'Content-Type': 'application/json', - } + return { 'Content-Type': 'application/json' } } - // Create merged headers with custom headers if they exist const customHeaders: Record = {} if (httpParams.headers) { - httpParams.headers.forEach((header: any) => { + ;( + httpParams.headers as Array<{ + Key?: string + Value?: string + cells?: Record + }> + ).forEach((header) => { if (header.Key || header.cells?.Key) { const key = header.Key || header.cells?.Key const value = header.Value || header.cells?.Value - customHeaders[key] = value + if (key && value) customHeaders[key] = value } }) } - // Add host header if missing try { - const hostname = new URL(httpParams.url).host + const hostname = new URL(httpParams.url as string).host if (hostname && !customHeaders.Host && !customHeaders.host) { customHeaders.Host = hostname } - } catch (_e) { - // Invalid URL, will be handled elsewhere + } catch { + // Invalid URL } - // Add content-type if body exists if (httpParams.body && !customHeaders['Content-Type'] && !customHeaders['content-type']) { customHeaders['Content-Type'] = 'application/json' } @@ -419,14 +404,13 @@ export class ToolTester

{ return createMockHeaders(customHeaders) } - // For other tools, use the regular pattern return this.tool.request.headers(params) } /** * Get request body that would be used for a request */ - getRequestBody(params: P): any { + getRequestBody(params: P): unknown { return this.tool.request.body ? this.tool.request.body(params) : undefined } } diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 2fafe98625..586f7fea59 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -118,6 +118,19 @@ export { type SerializedConnection, type SerializedWorkflow, } from './serialized-block.factory' +// Tool mock responses +export { + mockDriveResponses, + mockGitHubResponses, + mockGmailResponses, + mockHttpResponses, + mockPineconeResponses, + mockSerperResponses, + mockSheetsResponses, + mockSlackResponses, + mockSupabaseResponses, + mockTavilyResponses, +} from './tool-responses.factory' // Undo/redo operation factories export { type BaseOperation, @@ -149,10 +162,19 @@ export { type WorkspaceFactoryOptions, } from './user.factory' export { + createAgentWithToolsWorkflowState, createBranchingWorkflow, + createComplexWorkflowState, + createConditionalWorkflowState, + createInvalidSerializedWorkflow, + createInvalidWorkflowState, createLinearWorkflow, createLoopWorkflow, + createLoopWorkflowState, + createMinimalWorkflowState, + createMissingMetadataWorkflow, createParallelWorkflow, createWorkflowState, type WorkflowFactoryOptions, + type WorkflowStateFixture, } from './workflow.factory' diff --git a/apps/sim/tools/__test-utils__/mock-data.ts b/packages/testing/src/factories/tool-responses.factory.ts similarity index 89% rename from apps/sim/tools/__test-utils__/mock-data.ts rename to packages/testing/src/factories/tool-responses.factory.ts index b879e334f5..7440c9c6d6 100644 --- a/apps/sim/tools/__test-utils__/mock-data.ts +++ b/packages/testing/src/factories/tool-responses.factory.ts @@ -27,9 +27,10 @@ export const mockHttpResponses = { }, } -// Gmail Mock Data +/** + * Gmail Mock Data + */ export const mockGmailResponses = { - // List messages response messageList: { messages: [ { id: 'msg1', threadId: 'thread1' }, @@ -38,14 +39,10 @@ export const mockGmailResponses = { ], nextPageToken: 'token123', }, - - // Empty list response emptyList: { messages: [], resultSizeEstimate: 0, }, - - // Single message response singleMessage: { id: 'msg1', threadId: 'thread1', @@ -79,9 +76,10 @@ export const mockGmailResponses = { }, } -// Google Drive Mock Data +/** + * Google Drive Mock Data + */ export const mockDriveResponses = { - // List files response fileList: { files: [ { id: 'file1', name: 'Document1.docx', mimeType: 'application/vnd.google-apps.document' }, @@ -98,13 +96,9 @@ export const mockDriveResponses = { ], nextPageToken: 'drive-page-token', }, - - // Empty file list emptyFileList: { files: [], }, - - // Single file metadata fileMetadata: { id: 'file1', name: 'Document1.docx', @@ -117,9 +111,10 @@ export const mockDriveResponses = { }, } -// Google Sheets Mock Data +/** + * Google Sheets Mock Data + */ export const mockSheetsResponses = { - // Read range response rangeData: { range: 'Sheet1!A1:D5', majorDimension: 'ROWS', @@ -131,15 +126,11 @@ export const mockSheetsResponses = { ['Row4Col1', 'Row4Col2', 'Row4Col3', 'Row4Col4'], ], }, - - // Empty range emptyRange: { range: 'Sheet1!A1:D5', majorDimension: 'ROWS', values: [], }, - - // Update range response updateResponse: { spreadsheetId: 'spreadsheet123', updatedRange: 'Sheet1!A1:D5', @@ -149,17 +140,16 @@ export const mockSheetsResponses = { }, } -// Pinecone Mock Data +/** + * Pinecone Mock Data + */ export const mockPineconeResponses = { - // Vector embedding embedding: { embedding: Array(1536) .fill(0) .map(() => Math.random() * 2 - 1), metadata: { text: 'Sample text for embedding', id: 'embed-123' }, }, - - // Search results searchResults: { matches: [ { id: 'doc1', score: 0.92, metadata: { text: 'Matching text 1' } }, @@ -167,16 +157,15 @@ export const mockPineconeResponses = { { id: 'doc3', score: 0.78, metadata: { text: 'Matching text 3' } }, ], }, - - // Upsert response upsertResponse: { statusText: 'Created', }, } -// GitHub Mock Data +/** + * GitHub Mock Data + */ export const mockGitHubResponses = { - // Repository info repoInfo: { id: 12345, name: 'test-repo', @@ -200,8 +189,6 @@ export const mockGitHubResponses = { stargazers_count: 15, language: 'TypeScript', }, - - // PR creation response prResponse: { id: 12345, number: 42, @@ -209,24 +196,18 @@ export const mockGitHubResponses = { body: 'Test PR description', html_url: 'https://github.com/user/test-repo/pull/42', state: 'open', - user: { - login: 'user', - id: 54321, - }, + user: { login: 'user', id: 54321 }, created_at: '2025-03-15T10:00:00Z', updated_at: '2025-03-15T10:05:00Z', }, } -// Serper Search Mock Data +/** + * Serper Search Mock Data + */ export const mockSerperResponses = { - // Search results searchResults: { - searchParameters: { - q: 'test query', - gl: 'us', - hl: 'en', - }, + searchParameters: { q: 'test query', gl: 'us', hl: 'en' }, organic: [ { title: 'Test Result 1', @@ -255,9 +236,10 @@ export const mockSerperResponses = { }, } -// Slack Mock Data +/** + * Slack Mock Data + */ export const mockSlackResponses = { - // Message post response messageResponse: { ok: true, channel: 'C1234567890', @@ -269,17 +251,16 @@ export const mockSlackResponses = { team: 'T1234567890', }, }, - - // Error response errorResponse: { ok: false, error: 'channel_not_found', }, } -// Tavily Mock Data +/** + * Tavily Mock Data + */ export const mockTavilyResponses = { - // Search results searchResults: { results: [ { @@ -306,9 +287,10 @@ export const mockTavilyResponses = { }, } -// Supabase Mock Data +/** + * Supabase Mock Data + */ export const mockSupabaseResponses = { - // Query response queryResponse: { data: [ { id: 1, name: 'Item 1', description: 'Description 1' }, @@ -317,20 +299,14 @@ export const mockSupabaseResponses = { ], error: null, }, - - // Insert response insertResponse: { data: [{ id: 4, name: 'Item 4', description: 'Description 4' }], error: null, }, - - // Update response updateResponse: { data: [{ id: 1, name: 'Updated Item 1', description: 'Updated Description 1' }], error: null, }, - - // Error response errorResponse: { data: null, error: { diff --git a/packages/testing/src/factories/workflow.factory.ts b/packages/testing/src/factories/workflow.factory.ts index 1132763536..c140249a91 100644 --- a/packages/testing/src/factories/workflow.factory.ts +++ b/packages/testing/src/factories/workflow.factory.ts @@ -209,3 +209,420 @@ export function createParallelWorkflow(count = 2): any { return createWorkflowState({ blocks, edges, parallels }) } + +/** + * Detailed workflow state fixture interface for serializer tests. + */ +export interface WorkflowStateFixture { + blocks: Record + edges: any[] + loops: Record +} + +/** + * Creates a minimal workflow with a starter and one agent block. + * Includes full subBlocks structure for serializer testing. + */ +export function createMinimalWorkflowState(): WorkflowStateFixture { + const blocks: Record = { + starter: { + id: 'starter', + type: 'starter', + name: 'Starter Block', + position: { x: 0, y: 0 }, + subBlocks: { + description: { id: 'description', type: 'long-input', value: 'This is the starter block' }, + }, + outputs: {}, + enabled: true, + }, + agent1: { + id: 'agent1', + type: 'agent', + name: 'Agent Block', + position: { x: 300, y: 0 }, + subBlocks: { + provider: { id: 'provider', type: 'dropdown', value: 'anthropic' }, + model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' }, + prompt: { id: 'prompt', type: 'long-input', value: 'Hello, world!' }, + tools: { id: 'tools', type: 'tool-input', value: '[]' }, + system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' }, + responseFormat: { id: 'responseFormat', type: 'code', value: null }, + }, + outputs: {}, + enabled: true, + }, + } + + return { + blocks, + edges: [{ id: 'edge1', source: 'starter', target: 'agent1' }], + loops: {}, + } +} + +/** + * Creates a workflow with condition blocks and branching paths. + */ +export function createConditionalWorkflowState(): WorkflowStateFixture { + const blocks: Record = { + starter: { + id: 'starter', + type: 'starter', + name: 'Starter Block', + position: { x: 0, y: 0 }, + subBlocks: { + description: { id: 'description', type: 'long-input', value: 'This is the starter block' }, + }, + outputs: {}, + enabled: true, + }, + condition1: { + id: 'condition1', + type: 'condition', + name: 'Condition Block', + position: { x: 300, y: 0 }, + subBlocks: { + condition: { id: 'condition', type: 'long-input', value: 'input.value > 10' }, + }, + outputs: {}, + enabled: true, + }, + agent1: { + id: 'agent1', + type: 'agent', + name: 'True Path Agent', + position: { x: 600, y: -100 }, + subBlocks: { + provider: { id: 'provider', type: 'dropdown', value: 'anthropic' }, + model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' }, + prompt: { id: 'prompt', type: 'long-input', value: 'Value is greater than 10' }, + tools: { id: 'tools', type: 'tool-input', value: '[]' }, + system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' }, + responseFormat: { id: 'responseFormat', type: 'code', value: null }, + }, + outputs: {}, + enabled: true, + }, + agent2: { + id: 'agent2', + type: 'agent', + name: 'False Path Agent', + position: { x: 600, y: 100 }, + subBlocks: { + provider: { id: 'provider', type: 'dropdown', value: 'anthropic' }, + model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' }, + prompt: { id: 'prompt', type: 'long-input', value: 'Value is less than or equal to 10' }, + tools: { id: 'tools', type: 'tool-input', value: '[]' }, + system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' }, + responseFormat: { id: 'responseFormat', type: 'code', value: null }, + }, + outputs: {}, + enabled: true, + }, + } + + return { + blocks, + edges: [ + { id: 'edge1', source: 'starter', target: 'condition1' }, + { id: 'edge2', source: 'condition1', target: 'agent1', sourceHandle: 'condition-true' }, + { id: 'edge3', source: 'condition1', target: 'agent2', sourceHandle: 'condition-false' }, + ], + loops: {}, + } +} + +/** + * Creates a workflow with a loop structure. + */ +export function createLoopWorkflowState(): WorkflowStateFixture { + const blocks: Record = { + starter: { + id: 'starter', + type: 'starter', + name: 'Starter Block', + position: { x: 0, y: 0 }, + subBlocks: { + description: { id: 'description', type: 'long-input', value: 'This is the starter block' }, + }, + outputs: {}, + enabled: true, + }, + function1: { + id: 'function1', + type: 'function', + name: 'Function Block', + position: { x: 300, y: 0 }, + subBlocks: { + code: { + id: 'code', + type: 'code', + value: 'let counter = input.counter || 0;\ncounter++;\nreturn { counter };', + }, + language: { id: 'language', type: 'dropdown', value: 'javascript' }, + }, + outputs: {}, + enabled: true, + }, + condition1: { + id: 'condition1', + type: 'condition', + name: 'Loop Condition', + position: { x: 600, y: 0 }, + subBlocks: { + condition: { id: 'condition', type: 'long-input', value: 'input.counter < 5' }, + }, + outputs: {}, + enabled: true, + }, + agent1: { + id: 'agent1', + type: 'agent', + name: 'Loop Complete Agent', + position: { x: 900, y: 100 }, + subBlocks: { + provider: { id: 'provider', type: 'dropdown', value: 'anthropic' }, + model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' }, + prompt: { + id: 'prompt', + type: 'long-input', + value: 'Loop completed after {{input.counter}} iterations', + }, + tools: { id: 'tools', type: 'tool-input', value: '[]' }, + system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' }, + responseFormat: { id: 'responseFormat', type: 'code', value: null }, + }, + outputs: {}, + enabled: true, + }, + } + + return { + blocks, + edges: [ + { id: 'edge1', source: 'starter', target: 'function1' }, + { id: 'edge2', source: 'function1', target: 'condition1' }, + { id: 'edge3', source: 'condition1', target: 'function1', sourceHandle: 'condition-true' }, + { id: 'edge4', source: 'condition1', target: 'agent1', sourceHandle: 'condition-false' }, + ], + loops: { + loop1: { id: 'loop1', nodes: ['function1', 'condition1'], iterations: 10, loopType: 'for' }, + }, + } +} + +/** + * Creates a complex workflow with multiple block types (API, function, agent). + */ +export function createComplexWorkflowState(): WorkflowStateFixture { + const blocks: Record = { + starter: { + id: 'starter', + type: 'starter', + name: 'Starter Block', + position: { x: 0, y: 0 }, + subBlocks: { + description: { id: 'description', type: 'long-input', value: 'This is the starter block' }, + }, + outputs: {}, + enabled: true, + }, + api1: { + id: 'api1', + type: 'api', + name: 'API Request', + position: { x: 300, y: 0 }, + subBlocks: { + url: { id: 'url', type: 'short-input', value: 'https://api.example.com/data' }, + method: { id: 'method', type: 'dropdown', value: 'GET' }, + headers: { + id: 'headers', + type: 'table', + value: [ + ['Content-Type', 'application/json'], + ['Authorization', 'Bearer {{API_KEY}}'], + ], + }, + body: { id: 'body', type: 'long-input', value: '' }, + }, + outputs: {}, + enabled: true, + }, + function1: { + id: 'function1', + type: 'function', + name: 'Process Data', + position: { x: 600, y: 0 }, + subBlocks: { + code: { + id: 'code', + type: 'code', + value: 'const data = input.data;\nreturn { processed: data.map(item => item.name) };', + }, + language: { id: 'language', type: 'dropdown', value: 'javascript' }, + }, + outputs: {}, + enabled: true, + }, + agent1: { + id: 'agent1', + type: 'agent', + name: 'Summarize Data', + position: { x: 900, y: 0 }, + subBlocks: { + provider: { id: 'provider', type: 'dropdown', value: 'openai' }, + model: { id: 'model', type: 'dropdown', value: 'gpt-4o' }, + prompt: { + id: 'prompt', + type: 'long-input', + value: 'Summarize the following data:\n\n{{input.processed}}', + }, + tools: { + id: 'tools', + type: 'tool-input', + value: + '[{"type":"function","name":"calculator","description":"Perform calculations","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math expression to evaluate"}},"required":["expression"]}}]', + }, + system: { id: 'system', type: 'long-input', value: 'You are a data analyst assistant.' }, + responseFormat: { + id: 'responseFormat', + type: 'code', + value: + '{"type":"object","properties":{"summary":{"type":"string"},"keyPoints":{"type":"array","items":{"type":"string"}},"sentiment":{"type":"string","enum":["positive","neutral","negative"]}},"required":["summary","keyPoints","sentiment"]}', + }, + }, + outputs: {}, + enabled: true, + }, + } + + return { + blocks, + edges: [ + { id: 'edge1', source: 'starter', target: 'api1' }, + { id: 'edge2', source: 'api1', target: 'function1' }, + { id: 'edge3', source: 'function1', target: 'agent1' }, + ], + loops: {}, + } +} + +/** + * Creates a workflow with an agent that has custom tools. + */ +export function createAgentWithToolsWorkflowState(): WorkflowStateFixture { + const blocks: Record = { + starter: { + id: 'starter', + type: 'starter', + name: 'Starter Block', + position: { x: 0, y: 0 }, + subBlocks: { + description: { id: 'description', type: 'long-input', value: 'This is the starter block' }, + }, + outputs: {}, + enabled: true, + }, + agent1: { + id: 'agent1', + type: 'agent', + name: 'Custom Tools Agent', + position: { x: 300, y: 0 }, + subBlocks: { + provider: { id: 'provider', type: 'dropdown', value: 'openai' }, + model: { id: 'model', type: 'dropdown', value: 'gpt-4o' }, + prompt: { + id: 'prompt', + type: 'long-input', + value: 'Use the tools to help answer: {{input.question}}', + }, + tools: { + id: 'tools', + type: 'tool-input', + value: + '[{"type":"custom-tool","name":"weather","description":"Get current weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}},{"type":"function","name":"calculator","description":"Calculate expression","parameters":{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}}]', + }, + system: { + id: 'system', + type: 'long-input', + value: 'You are a helpful assistant with access to tools.', + }, + responseFormat: { id: 'responseFormat', type: 'code', value: null }, + }, + outputs: {}, + enabled: true, + }, + } + + return { + blocks, + edges: [{ id: 'edge1', source: 'starter', target: 'agent1' }], + loops: {}, + } +} + +/** + * Creates a workflow state with an invalid block type for error testing. + */ +export function createInvalidWorkflowState(): WorkflowStateFixture { + const { blocks, edges, loops } = createMinimalWorkflowState() + + blocks.invalid = { + id: 'invalid', + type: 'invalid-type', + name: 'Invalid Block', + position: { x: 600, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + } + + edges.push({ id: 'edge-invalid', source: 'agent1', target: 'invalid' }) + + return { blocks, edges, loops } +} + +/** + * Creates a serialized workflow with invalid metadata for error testing. + */ +export function createInvalidSerializedWorkflow() { + return { + version: '1.0', + blocks: [ + { + id: 'invalid', + position: { x: 0, y: 0 }, + config: { tool: 'invalid', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'non-existent-type' }, + enabled: true, + }, + ], + connections: [], + loops: {}, + } +} + +/** + * Creates a serialized workflow with missing metadata for error testing. + */ +export function createMissingMetadataWorkflow() { + return { + version: '1.0', + blocks: [ + { + id: 'invalid', + position: { x: 0, y: 0 }, + config: { tool: 'invalid', params: {} }, + inputs: {}, + outputs: {}, + metadata: undefined, + enabled: true, + }, + ], + connections: [], + loops: {}, + } +} diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 816fd496c6..8eab14fb87 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -48,17 +48,30 @@ export { createEnvMock, createMockDb, createMockFetch, + createMockFormDataRequest, createMockGetEnv, createMockLogger, + createMockRequest, createMockResponse, createMockSocket, createMockStorage, databaseMock, defaultMockEnv, + defaultMockUser, drizzleOrmMock, envMock, loggerMock, + type MockAuthResult, type MockFetchResponse, + type MockUser, + mockAuth, + mockCommonSchemas, + mockConsoleLogger, + mockCryptoUuid, + mockDrizzleOrm, + mockKnowledgeSchemas, + mockUuid, + setupCommonApiMocks, setupGlobalFetchMock, setupGlobalStorageMocks, } from './mocks' diff --git a/packages/testing/src/mocks/api.mock.ts b/packages/testing/src/mocks/api.mock.ts new file mode 100644 index 0000000000..2a4acd9261 --- /dev/null +++ b/packages/testing/src/mocks/api.mock.ts @@ -0,0 +1,198 @@ +/** + * Mock utilities for API testing + */ +import { vi } from 'vitest' +import { createMockLogger } from './logger.mock' + +/** + * Mock drizzle-orm operators for database query testing. + * Provides mock implementations of common drizzle-orm operators. + * + * @example + * ```ts + * mockDrizzleOrm() + * // Now eq, and, or, etc. from drizzle-orm are mocked + * ``` + */ +export function mockDrizzleOrm() { + vi.doMock('drizzle-orm', () => ({ + and: vi.fn((...conditions) => ({ conditions, type: 'and' })), + eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), + or: vi.fn((...conditions) => ({ type: 'or', conditions })), + gte: vi.fn((field, value) => ({ type: 'gte', field, value })), + lte: vi.fn((field, value) => ({ type: 'lte', field, value })), + gt: vi.fn((field, value) => ({ type: 'gt', field, value })), + lt: vi.fn((field, value) => ({ type: 'lt', field, value })), + ne: vi.fn((field, value) => ({ type: 'ne', field, value })), + asc: vi.fn((field) => ({ field, type: 'asc' })), + desc: vi.fn((field) => ({ field, type: 'desc' })), + isNull: vi.fn((field) => ({ field, type: 'isNull' })), + isNotNull: vi.fn((field) => ({ field, type: 'isNotNull' })), + inArray: vi.fn((field, values) => ({ field, values, type: 'inArray' })), + notInArray: vi.fn((field, values) => ({ field, values, type: 'notInArray' })), + like: vi.fn((field, value) => ({ field, value, type: 'like' })), + ilike: vi.fn((field, value) => ({ field, value, type: 'ilike' })), + count: vi.fn((field) => ({ field, type: 'count' })), + sum: vi.fn((field) => ({ field, type: 'sum' })), + avg: vi.fn((field) => ({ field, type: 'avg' })), + min: vi.fn((field) => ({ field, type: 'min' })), + max: vi.fn((field) => ({ field, type: 'max' })), + sql: vi.fn((strings, ...values) => ({ + type: 'sql', + sql: strings, + values, + })), + })) +} + +/** + * Mock common database schema patterns. + * Provides mock schema objects for common tables. + * + * @example + * ```ts + * mockCommonSchemas() + * // Now @sim/db/schema exports are mocked + * ``` + */ +export function mockCommonSchemas() { + vi.doMock('@sim/db/schema', () => ({ + workflowFolder: { + id: 'id', + userId: 'userId', + parentId: 'parentId', + updatedAt: 'updatedAt', + workspaceId: 'workspaceId', + sortOrder: 'sortOrder', + createdAt: 'createdAt', + }, + workflow: { + id: 'id', + folderId: 'folderId', + userId: 'userId', + updatedAt: 'updatedAt', + }, + account: { + userId: 'userId', + providerId: 'providerId', + }, + user: { + email: 'email', + id: 'id', + }, + })) +} + +/** + * Mock console logger using the shared mock logger. + * Ensures tests can assert on logger calls. + * + * @example + * ```ts + * mockConsoleLogger() + * // Now @sim/logger.createLogger returns a mock logger + * ``` + */ +export function mockConsoleLogger() { + const mockLogger = createMockLogger() + vi.doMock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + return mockLogger +} + +/** + * Setup common API test mocks (schemas, drizzle ORM). + * Does NOT set up logger mocks - call mockConsoleLogger() separately if needed. + * + * @example + * ```ts + * setupCommonApiMocks() + * const mockLogger = mockConsoleLogger() // Call separately to get logger instance + * ``` + */ +export function setupCommonApiMocks() { + mockCommonSchemas() + mockDrizzleOrm() +} + +/** + * Mock knowledge-related database schemas. + * Provides mock schema objects for knowledge base tables. + * + * @example + * ```ts + * mockKnowledgeSchemas() + * // Now @sim/db/schema exports knowledge base tables + * ``` + */ +export function mockKnowledgeSchemas() { + vi.doMock('@sim/db/schema', () => ({ + knowledgeBase: { + id: 'kb_id', + userId: 'user_id', + name: 'kb_name', + description: 'description', + tokenCount: 'token_count', + embeddingModel: 'embedding_model', + embeddingDimension: 'embedding_dimension', + chunkingConfig: 'chunking_config', + workspaceId: 'workspace_id', + createdAt: 'created_at', + updatedAt: 'updated_at', + deletedAt: 'deleted_at', + }, + document: { + id: 'doc_id', + knowledgeBaseId: 'kb_id', + filename: 'filename', + fileUrl: 'file_url', + fileSize: 'file_size', + mimeType: 'mime_type', + chunkCount: 'chunk_count', + tokenCount: 'token_count', + characterCount: 'character_count', + processingStatus: 'processing_status', + processingStartedAt: 'processing_started_at', + processingCompletedAt: 'processing_completed_at', + processingError: 'processing_error', + enabled: 'enabled', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + uploadedAt: 'uploaded_at', + deletedAt: 'deleted_at', + }, + embedding: { + id: 'embedding_id', + documentId: 'doc_id', + knowledgeBaseId: 'kb_id', + chunkIndex: 'chunk_index', + content: 'content', + embedding: 'embedding', + tokenCount: 'token_count', + characterCount: 'character_count', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + createdAt: 'created_at', + }, + permissions: { + id: 'permission_id', + userId: 'user_id', + entityType: 'entity_type', + entityId: 'entity_id', + permissionType: 'permission_type', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + })) +} diff --git a/packages/testing/src/mocks/auth.mock.ts b/packages/testing/src/mocks/auth.mock.ts new file mode 100644 index 0000000000..209d93b34d --- /dev/null +++ b/packages/testing/src/mocks/auth.mock.ts @@ -0,0 +1,74 @@ +/** + * Mock authentication utilities for API testing + */ +import { vi } from 'vitest' + +/** + * Mock user interface for authentication testing + */ +export interface MockUser { + id: string + email: string + name?: string +} + +/** + * Result object returned by mockAuth with helper methods + */ +export interface MockAuthResult { + /** The mock getSession function */ + mockGetSession: ReturnType + /** Set authenticated state with optional custom user */ + setAuthenticated: (user?: MockUser) => void + /** Set unauthenticated state (session returns null) */ + setUnauthenticated: () => void + /** Alias for setAuthenticated */ + mockAuthenticatedUser: (user?: MockUser) => void + /** Alias for setUnauthenticated */ + mockUnauthenticated: () => void +} + +/** + * Default mock user for testing + */ +export const defaultMockUser: MockUser = { + id: 'user-123', + email: 'test@example.com', +} + +/** + * Mock authentication for API tests. + * Uses vi.doMock to mock the auth module's getSession function. + * + * @param user - Optional user object to use for authenticated requests + * @returns Object with authentication helper functions + * + * @example + * ```ts + * const auth = mockAuth() + * auth.setAuthenticated() // User is now authenticated + * auth.setUnauthenticated() // User is now unauthenticated + * + * // With custom user + * auth.setAuthenticated({ id: 'custom-id', email: 'custom@test.com' }) + * ``` + */ +export function mockAuth(user: MockUser = defaultMockUser): MockAuthResult { + const mockGetSession = vi.fn() + + vi.doMock('@/lib/auth', () => ({ + getSession: mockGetSession, + })) + + const setAuthenticated = (customUser?: MockUser) => + mockGetSession.mockResolvedValue({ user: customUser || user }) + const setUnauthenticated = () => mockGetSession.mockResolvedValue(null) + + return { + mockGetSession, + mockAuthenticatedUser: setAuthenticated, + mockUnauthenticated: setUnauthenticated, + setAuthenticated, + setUnauthenticated, + } +} diff --git a/packages/testing/src/mocks/blocks.mock.ts b/packages/testing/src/mocks/blocks.mock.ts new file mode 100644 index 0000000000..411dd29414 --- /dev/null +++ b/packages/testing/src/mocks/blocks.mock.ts @@ -0,0 +1,300 @@ +/** + * Mock block configurations for serializer and related tests. + * + * @example + * ```ts + * import { blocksMock, mockBlockConfigs } from '@sim/testing/mocks' + * + * vi.mock('@/blocks', () => blocksMock) + * + * // Or use individual configs + * const starterConfig = mockBlockConfigs.starter + * ``` + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Mock block configurations that mirror the real block registry. + * Used for testing serialization, deserialization, and validation. + */ +export const mockBlockConfigs: Record = { + starter: { + name: 'Starter', + description: 'Start of the workflow', + category: 'flow', + bgColor: '#4CAF50', + tools: { + access: ['starter'], + config: { tool: () => 'starter' }, + }, + subBlocks: [ + { id: 'description', type: 'long-input', label: 'Description' }, + { id: 'inputFormat', type: 'table', label: 'Input Format' }, + ], + inputs: {}, + }, + agent: { + name: 'Agent', + description: 'AI Agent', + category: 'ai', + bgColor: '#2196F3', + tools: { + access: ['anthropic_chat', 'openai_chat', 'google_chat'], + config: { + tool: (params: Record) => { + const model = params.model || 'gpt-4o' + if (model.includes('claude')) return 'anthropic' + if (model.includes('gpt') || model.includes('o1')) return 'openai' + if (model.includes('gemini')) return 'google' + return 'openai' + }, + }, + }, + subBlocks: [ + { id: 'provider', type: 'dropdown', label: 'Provider' }, + { id: 'model', type: 'dropdown', label: 'Model' }, + { id: 'prompt', type: 'long-input', label: 'Prompt' }, + { id: 'system', type: 'long-input', label: 'System Message' }, + { id: 'tools', type: 'tool-input', label: 'Tools' }, + { id: 'responseFormat', type: 'code', label: 'Response Format' }, + { id: 'messages', type: 'messages-input', label: 'Messages' }, + ], + inputs: { + input: { type: 'string' }, + tools: { type: 'array' }, + }, + }, + function: { + name: 'Function', + description: 'Execute custom code', + category: 'code', + bgColor: '#9C27B0', + tools: { + access: ['function'], + config: { tool: () => 'function' }, + }, + subBlocks: [ + { id: 'code', type: 'code', label: 'Code' }, + { id: 'language', type: 'dropdown', label: 'Language' }, + ], + inputs: { input: { type: 'any' } }, + }, + condition: { + name: 'Condition', + description: 'Branch based on condition', + category: 'flow', + bgColor: '#FF9800', + tools: { + access: ['condition'], + config: { tool: () => 'condition' }, + }, + subBlocks: [{ id: 'condition', type: 'long-input', label: 'Condition' }], + inputs: { input: { type: 'any' } }, + }, + api: { + name: 'API', + description: 'Make API request', + category: 'data', + bgColor: '#E91E63', + tools: { + access: ['api'], + config: { tool: () => 'api' }, + }, + subBlocks: [ + { id: 'url', type: 'short-input', label: 'URL' }, + { id: 'method', type: 'dropdown', label: 'Method' }, + { id: 'headers', type: 'table', label: 'Headers' }, + { id: 'body', type: 'long-input', label: 'Body' }, + ], + inputs: {}, + }, + webhook: { + name: 'Webhook', + description: 'Webhook trigger', + category: 'triggers', + bgColor: '#4CAF50', + tools: { + access: ['webhook'], + config: { tool: () => 'webhook' }, + }, + subBlocks: [{ id: 'path', type: 'short-input', label: 'Path' }], + inputs: {}, + }, + jina: { + name: 'Jina', + description: 'Convert website content into text', + category: 'tools', + bgColor: '#333333', + tools: { + access: ['jina_read_url'], + config: { tool: () => 'jina_read_url' }, + }, + subBlocks: [ + { id: 'url', type: 'short-input', title: 'URL', required: true }, + { id: 'apiKey', type: 'short-input', title: 'API Key', required: true }, + ], + inputs: { + url: { type: 'string' }, + apiKey: { type: 'string' }, + }, + }, + reddit: { + name: 'Reddit', + description: 'Access Reddit data and content', + category: 'tools', + bgColor: '#FF5700', + tools: { + access: ['reddit_get_posts', 'reddit_get_comments'], + config: { tool: () => 'reddit_get_posts' }, + }, + subBlocks: [ + { id: 'operation', type: 'dropdown', title: 'Operation', required: true }, + { id: 'credential', type: 'oauth-input', title: 'Reddit Account', required: true }, + { id: 'subreddit', type: 'short-input', title: 'Subreddit', required: true }, + ], + inputs: { + operation: { type: 'string' }, + credential: { type: 'string' }, + subreddit: { type: 'string' }, + }, + }, + slack: { + name: 'Slack', + description: 'Send messages to Slack', + category: 'tools', + bgColor: '#611f69', + tools: { + access: ['slack_send_message'], + config: { tool: () => 'slack_send_message' }, + }, + subBlocks: [ + { id: 'channel', type: 'dropdown', title: 'Channel', mode: 'basic' }, + { id: 'manualChannel', type: 'short-input', title: 'Channel ID', mode: 'advanced' }, + { id: 'text', type: 'long-input', title: 'Message' }, + { id: 'username', type: 'short-input', title: 'Username', mode: 'both' }, + ], + inputs: { + channel: { type: 'string' }, + manualChannel: { type: 'string' }, + text: { type: 'string' }, + username: { type: 'string' }, + }, + }, + agentWithMemories: { + name: 'Agent with Memories', + description: 'AI Agent with memory support', + category: 'ai', + bgColor: '#2196F3', + tools: { + access: ['anthropic_chat'], + config: { tool: () => 'anthropic_chat' }, + }, + subBlocks: [ + { id: 'systemPrompt', type: 'long-input', title: 'System Prompt' }, + { id: 'userPrompt', type: 'long-input', title: 'User Prompt' }, + { id: 'memories', type: 'short-input', title: 'Memories', mode: 'advanced' }, + { id: 'model', type: 'dropdown', title: 'Model' }, + ], + inputs: { + systemPrompt: { type: 'string' }, + userPrompt: { type: 'string' }, + memories: { type: 'array' }, + model: { type: 'string' }, + }, + }, + conditional_block: { + name: 'Conditional Block', + description: 'Block with conditional fields', + category: 'tools', + bgColor: '#FF5700', + tools: { + access: ['conditional_tool'], + config: { tool: () => 'conditional_tool' }, + }, + subBlocks: [ + { id: 'mode', type: 'dropdown', label: 'Mode' }, + { + id: 'optionA', + type: 'short-input', + label: 'Option A', + condition: { field: 'mode', value: 'a' }, + }, + { + id: 'optionB', + type: 'short-input', + label: 'Option B', + condition: { field: 'mode', value: 'b' }, + }, + { + id: 'notModeC', + type: 'short-input', + label: 'Not Mode C', + condition: { field: 'mode', value: 'c', not: true }, + }, + { + id: 'complexCondition', + type: 'short-input', + label: 'Complex', + condition: { field: 'mode', value: 'a', and: { field: 'optionA', value: 'special' } }, + }, + { + id: 'arrayCondition', + type: 'short-input', + label: 'Array Condition', + condition: { field: 'mode', value: ['a', 'b'] }, + }, + ], + inputs: {}, + }, +} + +/** + * Creates a getBlock function that returns mock block configs. + * Can be extended with additional block types. + */ +export function createMockGetBlock(extraConfigs: Record = {}) { + const configs = { ...mockBlockConfigs, ...extraConfigs } + return (type: string) => configs[type] || null +} + +/** + * Mock tool configurations for validation tests. + */ +export const mockToolConfigs: Record = { + jina_read_url: { + params: { + url: { visibility: 'user-or-llm', required: true }, + apiKey: { visibility: 'user-only', required: true }, + }, + }, + reddit_get_posts: { + params: { + subreddit: { visibility: 'user-or-llm', required: true }, + credential: { visibility: 'user-only', required: true }, + }, + }, +} + +/** + * Creates a getTool function that returns mock tool configs. + */ +export function createMockGetTool(extraConfigs: Record = {}) { + const configs = { ...mockToolConfigs, ...extraConfigs } + return (toolId: string) => configs[toolId] || null +} + +/** + * Pre-configured blocks mock for use with vi.mock('@/blocks', () => blocksMock). + */ +export const blocksMock = { + getBlock: createMockGetBlock(), + getAllBlocks: () => Object.values(mockBlockConfigs), +} + +/** + * Pre-configured tools/utils mock for use with vi.mock('@/tools/utils', () => toolsUtilsMock). + */ +export const toolsUtilsMock = { + getTool: createMockGetTool(), +} diff --git a/apps/sim/executor/__test-utils__/mock-dependencies.ts b/packages/testing/src/mocks/executor.mock.ts similarity index 63% rename from apps/sim/executor/__test-utils__/mock-dependencies.ts rename to packages/testing/src/mocks/executor.mock.ts index 999e65a6d6..8256a9e97c 100644 --- a/apps/sim/executor/__test-utils__/mock-dependencies.ts +++ b/packages/testing/src/mocks/executor.mock.ts @@ -1,8 +1,24 @@ -import { loggerMock, setupGlobalFetchMock } from '@sim/testing' +/** + * Mock utilities for executor handler testing. + * Sets up common mocks needed for testing executor block handlers. + * + * This module is designed to be imported for side effects - the vi.mock calls + * are executed at the top level and hoisted by vitest. + * + * @example + * ```ts + * // Import at the very top of your test file for side effects + * import '@sim/testing/mocks/executor.mock' + * + * // Then your other imports + * import { describe, it, expect } from 'vitest' + * ``` + */ import { vi } from 'vitest' +import { setupGlobalFetchMock } from './fetch.mock' +import { loggerMock } from './logger.mock' -// Mock common dependencies used across executor handler tests - +// Logger vi.mock('@sim/logger', () => loggerMock) // Blocks @@ -14,7 +30,7 @@ vi.mock('@/blocks/index', () => ({ vi.mock('@/tools/utils', () => ({ getTool: vi.fn(), getToolAsync: vi.fn(), - validateToolRequest: vi.fn(), // Keep for backward compatibility + validateToolRequest: vi.fn(), formatRequestParams: vi.fn(), transformTable: vi.fn(), createParamSchema: vi.fn(), @@ -32,21 +48,20 @@ vi.mock('@/lib/core/config/api-keys', () => ({ getRotatingApiKey: vi.fn(), })) -// Tools +// Tools module vi.mock('@/tools') // Providers vi.mock('@/providers', () => ({ executeProviderRequest: vi.fn(), })) + vi.mock('@/providers/utils', async (importOriginal) => { const actual = await importOriginal() return { - // @ts-ignore - ...actual, + ...(actual as object), getProviderFromModel: vi.fn(), transformBlockTool: vi.fn(), - // Ensure getBaseModelProviders returns an object getBaseModelProviders: vi.fn(() => ({})), } }) @@ -57,10 +72,10 @@ vi.mock('@/executor/resolver', () => ({ InputResolver: vi.fn(), })) -// Specific block utilities (like router prompt generator) +// Specific block utilities vi.mock('@/blocks/blocks/router') -// Mock blocks - needed by agent handler for transformBlockTool +// Mock blocks module vi.mock('@/blocks') // Mock fetch for server requests diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 52eec208c9..91013cae01 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -16,6 +16,30 @@ * ``` */ +// API mocks +export { + mockCommonSchemas, + mockConsoleLogger, + mockDrizzleOrm, + mockKnowledgeSchemas, + setupCommonApiMocks, +} from './api.mock' +// Auth mocks +export { + defaultMockUser, + type MockAuthResult, + type MockUser, + mockAuth, +} from './auth.mock' +// Blocks mocks +export { + blocksMock, + createMockGetBlock, + createMockGetTool, + mockBlockConfigs, + mockToolConfigs, + toolsUtilsMock, +} from './blocks.mock' // Database mocks export { createMockDb, @@ -26,6 +50,7 @@ export { } from './database.mock' // Env mocks export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock' +// Executor mocks - use side-effect import: import '@sim/testing/mocks/executor' // Fetch mocks export { createMockFetch, @@ -38,6 +63,8 @@ export { } from './fetch.mock' // Logger mocks export { clearLoggerMocks, createMockLogger, getLoggerCalls, loggerMock } from './logger.mock' +// Request mocks +export { createMockFormDataRequest, createMockRequest } from './request.mock' // Socket mocks export { createMockSocket, @@ -47,3 +74,5 @@ export { } from './socket.mock' // Storage mocks export { clearStorageMocks, createMockStorage, setupGlobalStorageMocks } from './storage.mock' +// UUID mocks +export { mockCryptoUuid, mockUuid } from './uuid.mock' diff --git a/packages/testing/src/mocks/request.mock.ts b/packages/testing/src/mocks/request.mock.ts new file mode 100644 index 0000000000..2e9a86fd6f --- /dev/null +++ b/packages/testing/src/mocks/request.mock.ts @@ -0,0 +1,59 @@ +/** + * Mock request utilities for API testing + */ + +/** + * Creates a mock NextRequest for API route testing. + * This is a general-purpose utility for testing Next.js API routes. + * + * @param method - HTTP method (GET, POST, PUT, DELETE, etc.) + * @param body - Optional request body (will be JSON stringified) + * @param headers - Optional headers to include + * @param url - Optional custom URL (defaults to http://localhost:3000/api/test) + * @returns NextRequest instance + * + * @example + * ```ts + * const req = createMockRequest('POST', { name: 'test' }) + * const response = await POST(req) + * ``` + */ +export function createMockRequest( + method = 'GET', + body?: unknown, + headers: Record = {}, + url = 'http://localhost:3000/api/test' +): Request { + const init: RequestInit = { + method, + headers: new Headers({ + 'Content-Type': 'application/json', + ...headers, + }), + } + + if (body !== undefined) { + init.body = JSON.stringify(body) + } + + return new Request(new URL(url), init) +} + +/** + * Creates a mock NextRequest with form data for file upload testing. + * + * @param formData - FormData instance + * @param method - HTTP method (defaults to POST) + * @param url - Optional custom URL + * @returns Request instance + */ +export function createMockFormDataRequest( + formData: FormData, + method = 'POST', + url = 'http://localhost:3000/api/test' +): Request { + return new Request(new URL(url), { + method, + body: formData, + }) +} diff --git a/packages/testing/src/mocks/uuid.mock.ts b/packages/testing/src/mocks/uuid.mock.ts new file mode 100644 index 0000000000..f278e76b64 --- /dev/null +++ b/packages/testing/src/mocks/uuid.mock.ts @@ -0,0 +1,40 @@ +/** + * Mock UUID utilities for testing + */ +import { vi } from 'vitest' + +/** + * Mock UUID v4 generation for consistent test results. + * Uses vi.doMock to mock the uuid module. + * + * @param mockValue - The UUID value to return (defaults to 'test-uuid') + * + * @example + * ```ts + * mockUuid('my-test-uuid') + * // Now uuid.v4() will return 'my-test-uuid' + * ``` + */ +export function mockUuid(mockValue = 'test-uuid') { + vi.doMock('uuid', () => ({ + v4: vi.fn().mockReturnValue(mockValue), + })) +} + +/** + * Mock crypto.randomUUID for tests. + * Uses vi.stubGlobal to replace the global crypto object. + * + * @param mockValue - The UUID value to return (defaults to 'mock-uuid-1234-5678') + * + * @example + * ```ts + * mockCryptoUuid('custom-uuid') + * // Now crypto.randomUUID() will return 'custom-uuid' + * ``` + */ +export function mockCryptoUuid(mockValue = 'mock-uuid-1234-5678') { + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue(mockValue), + }) +}