Files
2026-07-13 12:38:34 +08:00

1519 lines
53 KiB
TypeScript

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<string, unknown> | 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<Record<string, unknown>>;
};
cliUserConfig?: {
developerModeEnabled?: boolean;
developerDangerousCommandsEnabled?: boolean;
experimentalFeatures?: Record<string, boolean>;
};
/**
* 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<SessionCreateResponse>;
/** Override `session.execute`. Receives sessionId and params. */
execute?: (sessionId: string, params: SessionExecuteParams) => Promise<SessionExecuteResponse>;
/** Override `session.executeMeta`. Receives sessionId and params. */
executeMeta?: (
sessionId: string,
params: SessionExecuteMetaParams
) => Promise<SessionExecuteMetaResponse>;
/** Override `session.link`. Receives sessionId and params. */
link?: (sessionId: string, params: SessionLinkParams) => Promise<SessionLinkResponse>;
/** Override `session.proxyExecute`. Receives sessionId and params. */
proxyExecute?: (
sessionId: string,
params: SessionProxyExecuteParams
) => Promise<SessionProxyExecuteResponse>;
/** Override `session.search`. Receives sessionId and params. */
search?: (sessionId: string, params: SessionSearchParams) => Promise<SessionSearchResponse>;
/** Override `session.toolkits`. Receives sessionId and optional params. */
toolkits?: (
sessionId: string,
params?: SessionToolkitsParams | null
) => Promise<SessionToolkitsResponse>;
};
/**
* 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<RequirementsOut, Error, RequirementsIn>
*/
type RequiredLayer = Layer.Layer<any, any, never>;
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<Record<string, unknown>>,
} 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<string>) => {
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<string>) => {
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<string>) => {
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<string>) => {
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<ToolkitVersionSpec>) => {
// 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<string>
) => {
// 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<string>,
});
},
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<string, unknown>;
}
) =>
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.<method>`.
const toolRouterOverrides = input?.toolRouter;
const defaultToolkitsHandler = async (
_sessionId: string,
params?: SessionToolkitsParams | null
): Promise<SessionToolkitsResponse> => {
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<SessionSearchResponse> => {
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<string, unknown> }) => {
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<string, unknown>;
}
) => ({
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) =>
<E, A>(self: Effect.Effect<A, E, CliApp.CliApp.Environment>): Promise<A> =>
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<void, never, never> {
// 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;
});
}