import path from 'node:path'; import * as tempy from 'tempy'; import { CliApp, CliConfig } from '@effect/cli'; import { Command, FetchHttpClient, FileSystem } from '@effect/platform'; import { BunFileSystem, BunContext, BunPath } from '@effect/platform-bun'; import { ConfigProvider, Console, DateTime, Effect, Layer, Logger, LogLevel, Option, Schedule, String, } from 'effect'; import { ComposioCliConfig } from 'src/cli-config'; import * as MockConsole from './mock-console'; import * as MockTerminal from './mock-terminal'; import { TerminalUITest } from './terminal-ui-test'; import type { Toolkits, ToolkitDetailed } from 'src/models/toolkits'; import { NodeProcess } from 'src/services/node-process'; import { ComposioClientSingleton, ComposioSessionRepository, ComposioToolkitsRepository, HttpServerError, InvalidToolkitsError, InvalidToolkitVersionsError, type InvalidVersionDetail, } from 'src/services/composio-clients'; import type { ToolkitVersionOverrides } from 'src/effects/toolkit-version-overrides'; import { JsPackageManagerDetector } from 'src/services/js-package-manager-detector'; import type { Tools } from 'src/models/tools'; import type { TriggerTypes, TriggerTypesAsEnums } from 'src/models/trigger-types'; import type { AuthConfigItem } from 'src/models/auth-configs'; import type { ConnectedAccountItem } from 'src/models/connected-accounts'; import type { TriggerInstanceItem } from 'src/models/triggers'; import type { AuthConfigCreateResponse, LinkCreateResponse } from 'src/services/composio-clients'; import type { ToolkitVersionSpec } from 'src/effects/toolkit-version-overrides'; import { ComposioUserContextLive } from 'src/services/user-context'; import { ComposioCliUserConfig } from 'src/services/cli-user-config'; import { CliUserConfig } from 'src/models/cli-user-config'; import { UpgradeBinary } from 'src/services/upgrade-binary'; import { NodeOs } from 'src/services/node-os'; import { TriggersRealtime } from 'src/services/triggers-realtime'; import { ToolsExecutor, ToolsExecutorLive } from 'src/services/tools-executor'; import type { ToolExecuteResponse } from 'src/services/tools-executor'; import type { SessionCreateResponse, SessionExecuteResponse, SessionExecuteMetaResponse, SessionLinkResponse, SessionSearchResponse, SessionToolkitsResponse, SessionProxyExecuteResponse, SessionCreateParams, SessionExecuteParams, SessionExecuteMetaParams, SessionLinkParams, SessionProxyExecuteParams, SessionSearchParams, SessionToolkitsParams, } from '@composio/client/resources/tool-router'; import { Stdin } from 'src/services/stdin'; import { ProjectContext } from 'src/services/project-context'; import { ProjectEnvironmentDetector } from 'src/services/project-environment-detector'; import { CommandRunner } from 'src/services/command-runner'; import { TerminalUI } from 'src/services/terminal-ui'; import { CommandExecutor } from '@effect/platform'; export interface TestLiveInput { /** * Base config provider to use in test. * If not provided, the default `ConfigProvider.fromMap(new Map([]))` is used. */ baseConfigProvider?: ConfigProvider.ConfigProvider; /** * Fixture to use in test. * TODO: consider extracting `fixture` into another `Effect`. */ fixture?: string; /** * Mock toolkit-related data to use in test. */ toolkitsData?: { toolkits?: Toolkits; detailedToolkits?: ToolkitDetailed[]; tools?: Tools; triggerTypesAsEnums?: TriggerTypesAsEnums; triggerTypes?: TriggerTypes; }; /** * Mock auth-config data to use in test. */ authConfigsData?: { items?: AuthConfigItem[]; createResponse?: AuthConfigCreateResponse; }; /** * Mock connected-account data to use in test. */ connectedAccountsData?: { items?: ConnectedAccountItem[]; linkResponse?: LinkCreateResponse; onPatch?: (params: { path: string; body: Record | undefined }) => void; onDelete?: (nanoid: string) => void; }; /** * Mock trigger instance data to use in test. */ triggersData?: { items?: TriggerInstanceItem[]; }; /** * Mock realtime trigger data to use in test. */ realtimeData?: { events?: ReadonlyArray>; }; cliUserConfig?: { developerModeEnabled?: boolean; developerDangerousCommandsEnabled?: boolean; experimentalFeatures?: Record; }; /** * Mock stdin for commands that read input. */ stdin?: { isTTY: boolean; data: string; }; /** * Override tools executor behavior for tests. * * When set, the `ToolsExecutor` service is replaced with a canned mock * that bypasses the real `ToolsExecutorLive` and the Tool Router entirely. * Use this only for tests that need to control the executor response directly * (e.g. soft failure tests). * * When NOT set, the real `ToolsExecutorLive` is used, which flows through * the mock `ComposioClientSingleton` → Tool Router session mocks. */ toolsExecutor?: { failWith?: unknown; respondWith?: ToolExecuteResponse; }; /** * Override Tool Router session behavior for tests. * * Controls the mock `ComposioClientSingleton`'s `toolRouter.session.*` methods. * Each method can be overridden individually; defaults are provided for all. * * When `toolsExecutor` is also set, its mock takes precedence for `tools execute` * (the Tool Router mock is still used by `toolkits list`, `toolkits info`, etc.). */ toolRouter?: { /** Override `session.create`. Receives the create params. */ create?: (params: SessionCreateParams) => Promise; /** Override `session.execute`. Receives sessionId and params. */ execute?: (sessionId: string, params: SessionExecuteParams) => Promise; /** Override `session.executeMeta`. Receives sessionId and params. */ executeMeta?: ( sessionId: string, params: SessionExecuteMetaParams ) => Promise; /** Override `session.link`. Receives sessionId and params. */ link?: (sessionId: string, params: SessionLinkParams) => Promise; /** Override `session.proxyExecute`. Receives sessionId and params. */ proxyExecute?: ( sessionId: string, params: SessionProxyExecuteParams ) => Promise; /** Override `session.search`. Receives sessionId and params. */ search?: (sessionId: string, params: SessionSearchParams) => Promise; /** Override `session.toolkits`. Receives sessionId and optional params. */ toolkits?: ( sessionId: string, params?: SessionToolkitsParams | null ) => Promise; }; /** * Override CommandRunner behavior for tests. * When set, the `CommandRunner` service uses the provided mock instance. * When NOT set, uses a default mock that always returns exit code 0. */ commandRunner?: CommandRunner; /** * Override TerminalUI behavior for tests. * When set, replaces the default TerminalUITest (which auto-selects first option). */ terminalUI?: TerminalUI; } /** * Concrete Effect layer compositions for the Composio test suites. * * ┌─── The service to be created * │ ┌─── The possible error * │ │ ┌─── The required dependencies * ▼ ▼ ▼ * Layer */ type RequiredLayer = Layer.Layer; const ConsumerProjectResolveFetchMock = Layer.scopedDiscard( Effect.acquireRelease( Effect.sync(() => { const originalFetch = globalThis.fetch; globalThis.fetch = (async (requestInput: RequestInfo | URL, init?: RequestInit) => { const url = typeof requestInput === 'string' ? requestInput : requestInput instanceof URL ? requestInput.toString() : requestInput.url; if (url.includes('/api/v3/org/consumer/project/resolve')) { const headers = new Headers( requestInput instanceof Request ? requestInput.headers : undefined ); new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); const orgId = headers.get('x-org-id') ?? 'org_test'; return new Response( JSON.stringify({ project_id: 'consumer_project_id_test', project_nano_id: 'consumer_project_test', project_name: 'Consumer Project', org_id: orgId, project_type: 'CONSUMER', consumer_user_id: `consumer-user-${orgId}`, }), { status: 200, headers: { 'Content-Type': 'application/json' }, } ); } return originalFetch(requestInput, init); }) as typeof globalThis.fetch; return originalFetch; }), originalFetch => Effect.sync(() => { globalThis.fetch = originalFetch; }) ) ); /** * Effect layer that injects all the services needed for tests, using mocks to avoid * side-effects like unwanted HTTP requests to remote services. */ export const TestLayer = (input?: TestLiveInput) => Effect.gen(function* () { const defaultAppClientData = { toolkits: [] as Toolkits, detailedToolkits: [] as ToolkitDetailed[], tools: [] as Tools, triggerTypesAsEnums: [] as TriggerTypesAsEnums, triggerTypes: [] as TriggerTypes, } satisfies TestLiveInput['toolkitsData']; const fixture = input?.fixture; const toolkitsData = { ...defaultAppClientData, ...(input?.toolkitsData ?? {}), detailedToolkits: input?.toolkitsData?.detailedToolkits ?? defaultAppClientData.detailedToolkits, }; const defaultAuthConfigsData = { items: [] as AuthConfigItem[], createResponse: undefined as AuthConfigCreateResponse | undefined, } satisfies TestLiveInput['authConfigsData']; const authConfigsData = { ...defaultAuthConfigsData, ...(input?.authConfigsData ?? {}), }; const defaultConnectedAccountsData = { items: [] as ConnectedAccountItem[], linkResponse: undefined as LinkCreateResponse | undefined, } satisfies TestLiveInput['connectedAccountsData']; const connectedAccountsData = { ...defaultConnectedAccountsData, ...(input?.connectedAccountsData ?? {}), }; const defaultRealtimeData = { events: [] as ReadonlyArray>, } satisfies TestLiveInput['realtimeData']; const realtimeData = { ...defaultRealtimeData, ...(input?.realtimeData ?? {}), }; const defaultTriggersData = { items: [] as TriggerInstanceItem[], } satisfies TestLiveInput['triggersData']; const triggersData = { ...defaultTriggersData, ...(input?.triggersData ?? {}), }; const tempDir = tempy.temporaryDirectory({ prefix: 'test' }); const cwd = (yield* setupFixtureFolder({ fixture, tempDir })) ?? tempDir; const ComposioToolkitsRepositoryTest = Layer.succeed( ComposioToolkitsRepository, new ComposioToolkitsRepository({ getToolkits: () => Effect.succeed(toolkitsData.toolkits), getToolkitsBySlugs: (slugs: ReadonlyArray) => { const normalizedSlugs = new Set(slugs.map(s => String.toLowerCase(s))); const found = toolkitsData.toolkits.filter(t => normalizedSlugs.has(String.toLowerCase(t.slug)) ); if (found.length !== slugs.length) { const foundSlugs = new Set(found.map(t => String.toLowerCase(t.slug))); const notFound = slugs.filter(s => !foundSlugs.has(String.toLowerCase(s))); return Effect.fail( new InvalidToolkitsError({ invalidToolkits: [...notFound], availableToolkits: toolkitsData.toolkits.map(t => t.slug), }) ); } return Effect.succeed(found); }, getMetrics: () => Effect.succeed({ byteSize: 0, requests: 0 }), getToolsAsEnums: () => Effect.succeed(toolkitsData.tools.map(tool => tool.slug)), getTriggerTypesAsEnums: () => Effect.succeed(toolkitsData.triggerTypesAsEnums), getTriggerTypes: (toolkitSlugs?: ReadonlyArray) => { let triggers = toolkitsData.triggerTypes; if (toolkitSlugs && toolkitSlugs.length > 0) { const prefixes = toolkitSlugs.map(s => `${s.toUpperCase()}_`); triggers = triggers.filter(t => prefixes.some(p => t.slug.toUpperCase().startsWith(p))); } return Effect.succeed(triggers); }, getTriggerTypeDetailed: (slug: string) => { const found = toolkitsData.triggerTypes.find( trigger => trigger.slug.toUpperCase() === slug.toUpperCase() ); if (!found) { return Effect.fail( new HttpServerError({ cause: `Trigger type "${slug}" not found`, status: 404 }) ); } return Effect.succeed(found); }, getTools: (toolkitSlugs?: ReadonlyArray) => { let tools = toolkitsData.tools; if (toolkitSlugs && toolkitSlugs.length > 0) { const prefixes = toolkitSlugs.map(s => `${s.toUpperCase()}_`); tools = tools.filter(t => prefixes.some(p => t.slug.toUpperCase().startsWith(p))); } return Effect.succeed(tools); }, validateToolkits: (toolkitSlugs: ReadonlyArray) => { const normalizedInputSlugs = toolkitSlugs.map(slug => String.toLowerCase(slug)); const availableSlugs = toolkitsData.toolkits.map(toolkit => String.toLowerCase(toolkit.slug) ); const invalidSlugs = normalizedInputSlugs.filter(slug => !availableSlugs.includes(slug)); if (invalidSlugs.length > 0) { return Effect.fail( new InvalidToolkitsError({ invalidToolkits: invalidSlugs, availableToolkits: availableSlugs, }) ); } return Effect.succeed(normalizedInputSlugs); }, filterToolkitsBySlugs: (toolkits, toolkitSlugs) => { const normalizedSlugs = new Set(toolkitSlugs.map(slug => String.toLowerCase(slug))); return toolkits.filter(toolkit => normalizedSlugs.has(String.toLowerCase(toolkit.slug))); }, getToolsByVersionSpecs: (specs: ReadonlyArray) => { // Filter tools based on toolkit slugs from specs const toolkitSlugs = specs.map(s => s.toolkitSlug.toUpperCase()); const prefixes = toolkitSlugs.map(s => `${s}_`); const tools = toolkitsData.tools.filter(t => prefixes.some(p => t.slug.toUpperCase().startsWith(p)) ); return Effect.succeed(tools); }, validateToolkitVersions: ( overrides: ToolkitVersionOverrides, relevantToolkits?: ReadonlyArray ) => { // Mock implementation that validates against test fixture const invalidVersions: InvalidVersionDetail[] = []; const warnings: string[] = []; for (const [toolkit, version] of overrides) { // Check if toolkit should be validated if (relevantToolkits && !relevantToolkits.map(s => s.toLowerCase()).includes(toolkit)) { warnings.push(`Version override for "${toolkit}" will be ignored`); continue; } // Check if toolkit exists in the fixture const toolkitExists = toolkitsData.toolkits.some( t => String.toLowerCase(t.slug) === toolkit ); if (!toolkitExists) { return Effect.fail( new InvalidToolkitsError({ invalidToolkits: [toolkit], availableToolkits: toolkitsData.toolkits.map(t => t.slug), }) ); } // Mock: only accept 'latest' or versions matching pattern YYYYMMDD_NN const validPattern = /^\d{8}_\d{2}$/; if (version !== 'latest' && !validPattern.test(version)) { invalidVersions.push({ toolkit, requestedVersion: version, availableVersions: ['20250901_00', '20250815_00', '20250710_00'], }); } } if (invalidVersions.length > 0) { return Effect.fail(new InvalidToolkitVersionsError({ invalidVersions })); } return Effect.succeed({ validatedOverrides: overrides, warnings: warnings as ReadonlyArray, }); }, searchToolkits: (params: { search?: string; category?: string; limit?: number; cursor?: string; }) => { let results = [...toolkitsData.toolkits]; if (params.search) { const q = params.search.toLowerCase(); results = results.filter( t => t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q) || t.meta.description.toLowerCase().includes(q) ); } const limit = params.limit ?? 30; const items = results.slice(0, limit); return Effect.succeed({ items, total_items: results.length, total_pages: Math.ceil(results.length / limit), next_cursor: null, }); }, searchTools: (params: { search?: string; toolkit_slug?: string; tags?: string; limit?: number; cursor?: string; }) => { let results = [...toolkitsData.tools]; if (params.toolkit_slug) { const slugs = params.toolkit_slug.split(',').map(s => s.trim().toUpperCase() + '_'); results = results.filter(t => slugs.some(p => t.slug.toUpperCase().startsWith(p))); } if (params.search) { const q = params.search.toLowerCase(); results = results.filter( t => t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) ); } if (params.tags) { const tagList = params.tags.split(',').map(t => t.trim().toLowerCase()); results = results.filter(t => tagList.some(tag => t.tags.map(tt => tt.toLowerCase()).includes(tag)) ); } const limit = params.limit ?? 30; const items = results.slice(0, limit); return Effect.succeed({ items, total_pages: Math.ceil(results.length / limit), next_cursor: null, }); }, getToolDetailed: (slug: string) => { const found = toolkitsData.tools.find(t => t.slug.toUpperCase() === slug.toUpperCase()); if (!found) { return Effect.fail( new HttpServerError({ cause: `Tool "${slug}" not found`, status: 404 }) ); } // Derive toolkit slug from tool slug prefix (e.g. GMAIL_SEND_EMAIL -> gmail) const parts = found.slug.split('_'); const toolkitSlug = parts.length > 1 ? parts[0]!.toLowerCase() : ''; return Effect.succeed({ ...found, no_auth: false, toolkit: { name: toolkitSlug, slug: toolkitSlug }, }); }, getToolkitDetailed: (slug: string) => { const found = toolkitsData.detailedToolkits.find( t => t.slug.toLowerCase() === slug.toLowerCase() ); if (!found) { return Effect.fail( new HttpServerError({ cause: `Toolkit "${slug}" not found`, status: 404 }) ); } return Effect.succeed(found); }, listAuthConfigs: (params: { search?: string; toolkit_slug?: string; limit?: number; show_disabled?: boolean; }) => { let results = [...authConfigsData.items]; if (params.toolkit_slug) { const slugs = params.toolkit_slug.split(',').map(s => s.trim().toLowerCase()); results = results.filter(item => slugs.includes(item.toolkit.slug.toLowerCase())); } if (params.search) { const q = params.search.toLowerCase(); results = results.filter( item => item.name.toLowerCase().includes(q) || item.id.toLowerCase().includes(q) ); } const limit = params.limit ?? 30; const items = results.slice(0, limit); return Effect.succeed({ items, total_items: results.length, total_pages: Math.ceil(results.length / limit), current_page: 1, next_cursor: null, }); }, getAuthConfig: (nanoid: string) => { const found = authConfigsData.items.find(item => item.id === nanoid); if (!found) { return Effect.fail( new HttpServerError({ cause: `Auth config "${nanoid}" not found`, status: 404, details: { message: `Auth config "${nanoid}" not found.`, suggestedFix: 'Check the auth config ID and try again.', code: 404, }, }) ); } return Effect.succeed(found); }, createAuthConfig: () => Effect.succeed( authConfigsData.createResponse ?? { auth_config: { id: 'ac_test', auth_scheme: 'OAUTH2', is_composio_managed: true }, toolkit: { slug: 'test' }, } ), deleteAuthConfig: (nanoid: string) => { const found = authConfigsData.items.find(item => item.id === nanoid); if (!found) { return Effect.fail( new HttpServerError({ cause: `Auth config "${nanoid}" not found`, status: 404, details: { message: `Auth config "${nanoid}" not found.`, suggestedFix: 'Check the auth config ID and try again.', code: 404, }, }) ); } return Effect.succeed({}); }, listConnectedAccounts: (params: { toolkit_slugs?: string[]; user_ids?: string[]; statuses?: string[]; limit?: number; }) => { let results = [...connectedAccountsData.items]; if (params.toolkit_slugs && params.toolkit_slugs.length > 0) { const slugs = params.toolkit_slugs.map(s => s.toLowerCase()); results = results.filter(item => slugs.includes(item.toolkit.slug.toLowerCase())); } if (params.user_ids && params.user_ids.length > 0) { const ids = new Set(params.user_ids); results = results.filter(item => ids.has(item.user_id)); } if (params.statuses && params.statuses.length > 0) { const statuses = new Set(params.statuses); results = results.filter(item => statuses.has(item.status)); } const limit = params.limit ?? 30; const items = results.slice(0, limit); return Effect.succeed({ items, total_items: results.length, total_pages: Math.ceil(results.length / limit), current_page: 1, next_cursor: null, }); }, getConnectedAccount: (nanoid: string) => { const found = connectedAccountsData.items.find(item => item.id === nanoid); if (!found) { return Effect.fail( new HttpServerError({ cause: `Connected account "${nanoid}" not found`, status: 404, details: { message: `Connected account "${nanoid}" not found.`, suggestedFix: 'Check the connected account ID and try again.', code: 404, }, }) ); } return Effect.succeed(found); }, deleteConnectedAccount: (nanoid: string) => { const found = connectedAccountsData.items.find(item => item.id === nanoid); if (!found) { return Effect.fail( new HttpServerError({ cause: `Connected account "${nanoid}" not found`, status: 404, details: { message: `Connected account "${nanoid}" not found.`, suggestedFix: 'Check the connected account ID and try again.', code: 404, }, }) ); } return Effect.succeed({}); }, createConnectedAccountLink: (params: { auth_config_id: string; user_id: string }) => { if (connectedAccountsData.linkResponse) { return Effect.succeed(connectedAccountsData.linkResponse); } return Effect.succeed({ connected_account_id: 'con_test_link', expires_at: '2026-12-31T23:59:59Z', link_token: 'lt_test_token', redirect_url: `https://app.composio.dev/link?token=lt_test_token`, } satisfies LinkCreateResponse); }, listActiveTriggers: (params: { user_ids?: string[]; connected_account_ids?: string[]; auth_config_ids?: string[]; trigger_ids?: string[]; trigger_names?: string[]; show_disabled?: boolean; limit?: number; }) => { let results = [...triggersData.items]; const userIds = params.user_ids; if (userIds && userIds.length > 0) { const set = new Set(userIds); results = results.filter(item => set.has(item.user_id)); } const connectedAccountIds = params.connected_account_ids; if (connectedAccountIds && connectedAccountIds.length > 0) { const set = new Set(connectedAccountIds); results = results.filter(item => set.has(item.connected_account_id)); } const authConfigIds = params.auth_config_ids; if (authConfigIds && authConfigIds.length > 0) { const set = new Set(authConfigIds); results = results.filter(item => set.has(item.auth_config_id)); } const triggerIds = params.trigger_ids; if (triggerIds && triggerIds.length > 0) { const set = new Set(triggerIds); results = results.filter(item => set.has(item.id)); } const triggerNames = params.trigger_names; if (triggerNames && triggerNames.length > 0) { const set = new Set(triggerNames.map(name => name.toUpperCase())); results = results.filter(item => set.has(item.trigger_name.toUpperCase())); } const includeDisabled = params.show_disabled ?? false; if (!includeDisabled) { results = results.filter(item => item.disabled_at === null); } const limit = params.limit ?? 30; const items = results.slice(0, limit); return Effect.succeed({ items, total_items: results.length, total_pages: Math.ceil(results.length / limit), current_page: 1, next_cursor: null, }); }, createTrigger: ( triggerSlug: string, params?: { connected_account_id?: string; trigger_config?: Record; } ) => Effect.succeed({ trigger_id: `trg_${triggerSlug.toLowerCase()}_${params?.connected_account_id ?? 'new'}`, }), enableTrigger: () => Effect.succeed({ status: 'success' as const }), disableTrigger: () => Effect.succeed({ status: 'success' as const }), deleteTrigger: triggerId => Effect.succeed({ trigger_id: triggerId }), }) ); const ComposioSessionRepositoryTest = yield* setupComposioSessionRepository(); const TriggersRealtimeTest = Layer.succeed( TriggersRealtime, new TriggersRealtime({ listen: onEvent => Effect.gen(function* () { yield* Effect.forEach(realtimeData.events, event => Effect.sync(() => onEvent(event))); return yield* Effect.never; }), listenInProject: (_scope, onEvent) => Effect.gen(function* () { yield* Effect.forEach(realtimeData.events, event => Effect.sync(() => onEvent(event))); return yield* Effect.never; }), }) ); // Mock `node:os` const NodeOsTest = Layer.succeed( NodeOs, new NodeOs({ homedir: cwd, arch: 'arm64', platform: 'darwin', }) ); // Mock `node:process` const NodeProcessTest = Layer.succeed( NodeProcess, new NodeProcess({ cwd, platform: 'darwin', arch: 'arm64', }) ); const ComposioUserContextTest = Layer.provideMerge( ComposioUserContextLive, Layer.merge(BunFileSystem.layer, NodeOsTest) ); let rawCliUserConfig = CliUserConfig.make({ developer: { enabled: input?.cliUserConfig?.developerModeEnabled ?? true, destructiveActions: input?.cliUserConfig?.developerDangerousCommandsEnabled ?? false, }, experimentalFeatures: input?.cliUserConfig?.experimentalFeatures ?? {}, artifactDirectory: Option.none(), experimentalSubagent: Option.none(), security: 'auto', }); const ComposioCliUserConfigTest = Layer.succeed( ComposioCliUserConfig, ComposioCliUserConfig.of({ get data() { return { channel: 'beta' as const, developerModeEnabled: rawCliUserConfig.developer.enabled, developerDangerousCommandsEnabled: rawCliUserConfig.developer.destructiveActions, experimentalFeatures: rawCliUserConfig.experimentalFeatures, artifactDirectory: undefined, experimentalSubagentTarget: 'auto' as const, security: 'auto' as const, }; }, get raw() { return rawCliUserConfig; }, channel: 'beta', isDevModeEnabled: () => rawCliUserConfig.developer.enabled, areDeveloperDangerousCommandsEnabled: () => rawCliUserConfig.developer.destructiveActions, isExperimentalFeatureEnabled: feature => rawCliUserConfig.experimentalFeatures[feature] ?? true, update: next => Effect.sync(() => { rawCliUserConfig = CliUserConfig.make({ ...rawCliUserConfig, ...next, }); }), }) ); const UpgradeBinaryTest = Layer.provide( UpgradeBinary.Default, Layer.mergeAll(BunFileSystem.layer, FetchHttpClient.layer) ); const StdinTest = Layer.succeed( Stdin, Stdin.of({ isTTY: () => input?.stdin?.isTTY ?? true, readAll: () => Effect.succeed(input?.stdin?.data ?? ''), }) ); // --- Tool Router mock (ComposioClientSingleton) --- // Provides a fake `@composio/client` Composio instance with configurable // `toolRouter.session.*` methods. Each method has a sensible default that // can be overridden per-test via `input.toolRouter.`. const toolRouterOverrides = input?.toolRouter; const defaultToolkitsHandler = async ( _sessionId: string, params?: SessionToolkitsParams | null ): Promise => { let results: SessionToolkitsResponse['items'] = toolkitsData.toolkits.map(t => ({ slug: t.slug, name: t.name, meta: { description: t.meta.description, logo: '' }, is_no_auth: t.no_auth ?? false, enabled: true, connected_account: null, composio_managed_auth_schemes: [...(t.composio_managed_auth_schemes ?? [])], })); if (params?.search) { const q = params.search.toLowerCase(); results = results.filter( t => t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q) || t.meta.description.toLowerCase().includes(q) ); } if (params?.toolkits) { const slugSet = new Set(params.toolkits.map(s => s.toLowerCase())); results = results.filter(t => slugSet.has(t.slug.toLowerCase())); } const limit = params?.limit ?? 1000; const items = results.slice(0, limit); return { items, current_page: 1, total_items: results.length, total_pages: Math.ceil(results.length / limit), next_cursor: null, }; }; const defaultSearchHandler = async ( _sessionId: string, params: SessionSearchParams ): Promise => { const results = params.queries.map((query, index) => { const queryUseCase = query.use_case ?? ''; const normalizedQuery = queryUseCase.toLowerCase(); const matchedTools = toolkitsData.tools.filter( tool => tool.name.toLowerCase().includes(normalizedQuery) || tool.slug.toLowerCase().includes(normalizedQuery) || tool.description.toLowerCase().includes(normalizedQuery) ); return { index: index + 1, use_case: queryUseCase, primary_tool_slugs: matchedTools.map(tool => tool.slug), related_tool_slugs: [], toolkits: Array.from( new Set( matchedTools.map(tool => tool.slug.split('_')[0]?.toLowerCase() ?? '').filter(Boolean) ) ), matchedTools, }; }); const allMatchedTools = results.flatMap(result => result.matchedTools); const toolSchemas = Object.fromEntries( allMatchedTools.map(tool => [ tool.slug, { tool_slug: tool.slug, toolkit: tool.slug.split('_')[0]?.toLowerCase() ?? '', description: tool.description, hasFullSchema: true, input_schema: tool.input_parameters, output_schema: tool.output_parameters, }, ]) ); const toolkitConnectionStatuses = Array.from( new Set( allMatchedTools.map(tool => tool.slug.split('_')[0]?.toLowerCase() ?? '').filter(Boolean) ) ).map(toolkitSlug => ({ toolkit: toolkitSlug, description: `${toolkitSlug} toolkit`, has_active_connection: false, status_message: 'No active connection', })); return { success: true, error: null, results: results.map(({ matchedTools: _matchedTools, ...result }) => result), tool_schemas: toolSchemas, toolkit_connection_statuses: toolkitConnectionStatuses, next_steps_guidance: [], session: { id: 'trs_test_session', generate_id: false, instructions: 'Reuse this session id for follow-up calls.', }, time_info: { current_time_utc: '2026-01-01T00:00:00.000Z', current_time_utc_epoch_seconds: 1767225600, message: 'UTC time', }, }; }; const mockComposioClient = { link: { create: async (params: { auth_config_id: string; user_id: string }) => { const response = connectedAccountsData.linkResponse ?? { connected_account_id: 'con_test_link', expires_at: '2026-12-31T23:59:59Z', link_token: 'lt_test_token', redirect_url: `https://app.composio.dev/link?token=lt_test_token`, }; return response; }, }, patch: async (path: string, options?: { body?: Record }) => { connectedAccountsData.onPatch?.({ path, body: options?.body }); const match = path.match(/^\/api\/v3\/connected_accounts\/([^/]+)$/); if (!match) { throw new Error(`Unhandled PATCH path "${path}"`); } const connectedAccountId = match[1]; const account = connectedAccountsData.items.find(item => item.id === connectedAccountId); if (!account) { throw new Error(`Connected account "${connectedAccountId}" not found`); } if (typeof options?.body?.alias === 'string') { Object.assign(account as { alias?: string | null }, { alias: options.body.alias, }); } return account; }, connectedAccounts: { list: async (params?: { toolkit_slugs?: string[]; user_ids?: string[]; statuses?: string[]; limit?: number; }) => { let results = [...connectedAccountsData.items]; if (params?.toolkit_slugs && params.toolkit_slugs.length > 0) { const slugs = new Set(params.toolkit_slugs.map(slug => slug.toLowerCase())); results = results.filter(item => slugs.has(item.toolkit.slug.toLowerCase())); } if (params?.user_ids && params.user_ids.length > 0) { const userIds = new Set(params.user_ids); results = results.filter(item => userIds.has(item.user_id)); } if (params?.statuses && params.statuses.length > 0) { const statuses = new Set(params.statuses); results = results.filter(item => statuses.has(item.status)); } const limit = params?.limit ?? 30; return { items: results.slice(0, limit), total_items: results.length, total_pages: Math.ceil(results.length / limit), current_page: 1, next_cursor: null, }; }, retrieve: async (nanoid: string) => { const found = connectedAccountsData.items.find(item => item.id === nanoid); if (!found) { throw new Error(`Connected account "${nanoid}" not found`); } return found; }, delete: async (nanoid: string) => { const found = connectedAccountsData.items.find(item => item.id === nanoid); if (!found) { throw new Error(`Connected account "${nanoid}" not found`); } connectedAccountsData.onDelete?.(nanoid); return {}; }, }, triggerInstances: { upsert: async ( triggerSlug: string, params?: { connected_account_id?: string; trigger_config?: Record; } ) => ({ trigger_id: `trg_${triggerSlug.toLowerCase()}_${params?.connected_account_id ?? 'new'}`, }), manage: { update: async (triggerId: string, params: { status: 'enable' | 'disable' }) => ({ trigger_id: triggerId, status: params.status, }), delete: async (triggerId: string) => ({ trigger_id: triggerId }), }, }, toolkits: { retrieve: async (slug: string) => { const detailed = toolkitsData.detailedToolkits.find( t => t.slug.toLowerCase() === slug.toLowerCase() ); if (detailed) { return { name: detailed.name, slug: detailed.slug, is_local_toolkit: detailed.is_local_toolkit, composio_managed_auth_schemes: [...detailed.composio_managed_auth_schemes], no_auth: detailed.no_auth, meta: detailed.meta, }; } const found = toolkitsData.toolkits.find( t => t.slug.toLowerCase() === slug.toLowerCase() ); if (!found) { throw new Error(`Toolkit "${slug}" not found`); } return { name: found.name, slug: found.slug, is_local_toolkit: found.is_local_toolkit, composio_managed_auth_schemes: [...found.composio_managed_auth_schemes], no_auth: found.no_auth, meta: found.meta, }; }, }, files: { createPresignedURL: async (params: { filename: string; mimetype: string; md5: string; tool_slug: string; toolkit_slug: string; }) => ({ key: `uploads/${params.filename}`, new_presigned_url: 'https://s3.test.composio.dev/upload', }), }, toolRouter: { session: { create: toolRouterOverrides?.create ?? (async (params: SessionCreateParams) => ({ session_id: 'trs_test_session', config: { user_id: params.user_id }, mcp: { type: 'http' as const, url: 'https://mcp.test.composio.dev' }, tool_router_tools: ['COMPOSIO_SEARCH_TOOLS', 'COMPOSIO_MANAGE_CONNECTIONS'], })), execute: toolRouterOverrides?.execute ?? (async (_sessionId: string, params: SessionExecuteParams) => ({ data: { tool_slug: params.tool_slug, arguments: params.arguments }, error: null, log_id: 'log_test', })), executeMeta: toolRouterOverrides?.executeMeta ?? (async (_sessionId: string, params: SessionExecuteMetaParams) => ({ data: { slug: params.slug, arguments: params.arguments }, error: null, log_id: 'log_test', })), link: toolRouterOverrides?.link ?? (async () => ({ connected_account_id: 'con_test_link', link_token: 'lt_test_token', redirect_url: 'https://app.composio.dev/link?token=lt_test_token', account_type: 'PRIVATE' as const, })), proxyExecute: toolRouterOverrides?.proxyExecute ?? (async (_sessionId: string, params: SessionProxyExecuteParams) => ({ status: 200, data: { toolkit_slug: params.toolkit_slug, endpoint: params.endpoint, method: params.method, body: params.body ?? null, parameters: params.parameters ?? [], }, headers: {}, })), search: toolRouterOverrides?.search ?? defaultSearchHandler, toolkits: toolRouterOverrides?.toolkits ?? defaultToolkitsHandler, tools: async () => ({ items: [], }), }, }, }; const ComposioClientSingletonTest = Layer.succeed( ComposioClientSingleton, new ComposioClientSingleton({ get: Effect.fn(function* () { // Partial mock: only implements `toolRouter.session.*` methods used by // CLI commands under test. The full Composio client interface is too // large to mock completely for unit tests. // eslint-disable-next-line @typescript-eslint/no-explicit-any return mockComposioClient as any; }), getFor: Effect.fn(function* () { // eslint-disable-next-line @typescript-eslint/no-explicit-any return mockComposioClient as any; }), }) ); // --- ToolsExecutor --- // When `input.toolsExecutor` is set, use a canned mock (bypasses Tool Router). // Otherwise, use the real ToolsExecutorLive which flows through the mock ComposioClientSingleton. const ToolsExecutorTest = input?.toolsExecutor ? Layer.succeed( ToolsExecutor, ToolsExecutor.of({ execute: (slug, params) => { if (input.toolsExecutor!.failWith) { return Effect.fail(input.toolsExecutor!.failWith); } if (input.toolsExecutor!.respondWith) { return Effect.succeed(input.toolsExecutor!.respondWith); } return Effect.succeed({ data: { slug, params }, error: null, successful: true, logId: 'log_test', }); }, }) ) : Layer.provide(ToolsExecutorLive, ComposioClientSingletonTest); const CliConfigLive = CliConfig.layer(ComposioCliConfig); // CommandRunner mock — default returns exit code 0 const CommandRunnerTest = input?.commandRunner ? Layer.succeed(CommandRunner, input.commandRunner) : Layer.succeed( CommandRunner, new CommandRunner({ run: () => Effect.succeed(CommandExecutor.ExitCode(0)), }) ); // TerminalUI — use provided override or default TerminalUITest const TerminalUILayer = input?.terminalUI ? Layer.succeed(TerminalUI, input.terminalUI) : TerminalUITest; const _console = yield* MockConsole.make; const layers = Layer.mergeAll( Console.setConsole(_console), CliConfigLive, NodeProcessTest, UpgradeBinaryTest, ComposioCliUserConfigTest, ComposioUserContextTest, ComposioClientSingletonTest, ComposioSessionRepositoryTest, TriggersRealtimeTest, ComposioToolkitsRepositoryTest, JsPackageManagerDetector.Default, ProjectEnvironmentDetector.Default, CommandRunnerTest, ToolsExecutorTest, BunFileSystem.layer, BunContext.layer, MockTerminal.layer, BunPath.layer, FetchHttpClient.layer, ConsumerProjectResolveFetchMock, StdinTest, TerminalUILayer, Layer.provide( ProjectContext.Default, Layer.mergeAll(BunFileSystem.layer, NodeOsTest, NodeProcessTest) ) ) satisfies RequiredLayer; return layers; }).pipe( Logger.withMinimumLogLevel(LogLevel.Debug), Effect.scoped, Layer.unwrapEffect, Layer.provide( Layer.setConfigProvider(input?.baseConfigProvider ?? ConfigProvider.fromMap(new Map([]))) ) ); // Run @effect/vitest suite with TestLive layer export const runEffect = (input?: TestLiveInput) => (self: Effect.Effect): Promise => Effect.provide(self, TestLayer(input)).pipe(Effect.scoped, Effect.runPromise); function setupFixtureFolder({ fixture, tempDir }: { fixture?: string; tempDir: string }) { return Effect.gen(function* () { if (fixture === undefined) { return; } const fs = yield* FileSystem.FileSystem; const realFixturePath = path.resolve( new URL('.', import.meta.url).pathname, '..', '..', '__fixtures__', fixture ); const tmpFixturesPath = path.join(tempDir, 'test', '__fixtures__', fixture); yield* Effect.logDebug(`Using fixture at: ${tmpFixturesPath}`); // Retry the task with a delay between retries and a maximum of 3 retries const policy = Schedule.addDelay(Schedule.recurs(3), () => '100 millis'); // If all retries fail, run the fallback effect. // Use tar to skip heavy directories (.venv) that the global setup may have created. // tar --exclude is POSIX and available on any Linux/macOS without extra packages. const task = Effect.gen(function* () { yield* fs.makeDirectory(tmpFixturesPath, { recursive: true }); const tarCmd = Command.make( 'tar', '-cf', '-', '--exclude', '.venv', '-C', realFixturePath, '.' ).pipe(Command.pipeTo(Command.make('tar', '-xf', '-', '-C', tmpFixturesPath))); yield* tarCmd.pipe(Command.exitCode, Effect.provide(BunContext.layer)); }); const repeated = Effect.retryOrElse(policy, () => Effect.die(`Failed to copy fixture to: ${tmpFixturesPath}`) ); yield* repeated(task); yield* Effect.logDebug(`Copied fixture to: ${tmpFixturesPath}`); // Break symlinks in node_modules to isolate test from real packages const nodeModulesPath = path.join(tmpFixturesPath, 'node_modules'); yield* breakSymlinksInNodeModules(fs, nodeModulesPath); return tmpFixturesPath; }).pipe(Effect.provide(BunFileSystem.layer)); } /** * Breaks symlinks in node_modules to ensure test isolation. * - On Unix: Uses `find -type l` for O(1) shell call to detect all symlinks * - On Windows: Uses O(n) readLink approach for compatibility */ function breakSymlinksInNodeModules( fs: FileSystem.FileSystem, nodeModulesPath: string ): Effect.Effect { // Helper: break a symlink by replacing it with a copy of its target const breakSymlink = (symlinkPath: string) => Effect.gen(function* () { const realPath = yield* fs.realPath(symlinkPath); yield* Effect.logDebug(`Breaking symlink: ${symlinkPath} -> ${realPath}`); yield* fs.remove(symlinkPath, { recursive: true }); yield* fs.copy(realPath, symlinkPath); }); // Unix: Use `find` command for fast symlink detection const breakSymlinksUnix = Effect.gen(function* () { const findCmd = Command.make( 'find', nodeModulesPath, '-maxdepth', '2', '-type', 'l', '-not', '-path', '*/.*' ); const output = yield* findCmd.pipe(Command.string, Effect.provide(BunContext.layer)); const symlinks = output.trim().split('\n').filter(Boolean); if (symlinks.length === 0) { return; } yield* Effect.logDebug(`Found ${symlinks.length} symlinks to break`); yield* Effect.all(symlinks.map(breakSymlink), { concurrency: 'unbounded' }); }); // Windows: Use readLink to detect symlinks (O(n) but compatible) const breakSymlinksWindows = Effect.gen(function* () { const isSymlink = (p: string) => fs.readLink(p).pipe( Effect.map(() => true), Effect.catchAll(() => Effect.succeed(false)) ); const entries = yield* fs.readDirectory(nodeModulesPath); yield* Effect.all( entries.map(entry => { if (entry.startsWith('.')) { return Effect.void; } const entryPath = path.join(nodeModulesPath, entry); return Effect.gen(function* () { const isLink = yield* isSymlink(entryPath); if (isLink) { yield* breakSymlink(entryPath); } else if (entry.startsWith('@')) { const scopedEntries = yield* fs.readDirectory(entryPath); yield* Effect.all( scopedEntries.map(scopedEntry => { const scopedPath = path.join(entryPath, scopedEntry); return Effect.gen(function* () { const isScopedLink = yield* isSymlink(scopedPath); if (isScopedLink) { yield* breakSymlink(scopedPath); } }); }), { concurrency: 'unbounded' } ); } }); }), { concurrency: 'unbounded' } ); }); return Effect.gen(function* () { const exists = yield* fs.exists(nodeModulesPath); if (!exists) { return; } // Some fixtures can contain node_modules as a symlink (often broken in temp copies). // Normalize it first so tests can safely create nested paths like node_modules/@scope/pkg. const nodeModulesLink = yield* fs.readLink(nodeModulesPath).pipe( Effect.map(() => true), Effect.catchAll(() => Effect.succeed(false)) ); if (nodeModulesLink) { yield* fs .remove(nodeModulesPath, { recursive: true }) .pipe(Effect.catchAll(() => Effect.void)); yield* fs .makeDirectory(nodeModulesPath, { recursive: true }) .pipe(Effect.catchAll(() => Effect.void)); } const isWindows = process.platform === 'win32'; if (isWindows) { yield* breakSymlinksWindows; } else { yield* breakSymlinksUnix; } }).pipe(Effect.catchAll(() => Effect.void)); } function setupComposioSessionRepository() { return Effect.gen(function* () { const now = yield* DateTime.now; const sessionId = 'te00st11-d0c4-4efa-8117-c638886063e0'; const sessionCode = '001122'; const expiresAt = DateTime.add(now, { minutes: 10 }); const accountName = 'test-name'; const accountId = 'test-id'; const accountEmail = 'test.name@gmail.com'; const account = { name: accountName, id: accountId, email: accountEmail, }; const composioSessionRepositoryTest = new ComposioSessionRepository({ createSession: () => Effect.succeed({ id: sessionId, code: sessionCode, expiresAt, status: 'pending', }), getSession: () => Effect.succeed({ id: sessionId, code: sessionCode, expiresAt, status: 'pending', api_key: null, }), getRealtimeCredentials: () => Effect.succeed({ project_id: 'proj_test', pusher_key: 'pusher_test_key', pusher_cluster: 'mt1', }), authRealtimeChannel: () => Effect.succeed({ auth: 'mock:auth', channel_data: undefined, }), }); const ComposioSessionRepositoryTest = Layer.succeed( ComposioSessionRepository, composioSessionRepositoryTest ); return ComposioSessionRepositoryTest; }); }