Files
alighasami 3d5eaf9445
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
first commit
2026-03-17 16:22:57 +03:30

620 lines
20 KiB
TypeScript

/**
* Tests for V2 CLI entry point.
*
* These tests mock all external dependencies and verify that
* the CLI correctly orchestrates evaluation runs.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import type { Client } from 'langsmith/client';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types/workflow';
// Store mocks for dependencies
const mockParseEvaluationArgs = jest.fn();
const mockArgsToStageModels = jest.fn();
const mockSetupTestEnvironment = jest.fn();
const mockCreateAgent = jest.fn();
const mockGenerateRunId = jest.fn();
const mockIsWorkflowStateValues = jest.fn();
const mockLoadTestCasesFromCsv = jest.fn();
const mockConsumeGenerator = jest.fn();
const mockGetChatPayload = jest.fn();
const mockRunEvaluation = jest.fn();
const mockCreateConsoleLifecycle = jest.fn();
const mockCreateLLMJudgeEvaluator = jest.fn();
const mockCreateProgrammaticEvaluator = jest.fn();
const mockCreatePairwiseEvaluator = jest.fn();
const mockCreateExecutionEvaluator = jest.fn();
const mockSendWebhookNotification = jest.fn();
// Mock all external modules
jest.mock('../cli/argument-parser', () => ({
parseEvaluationArgs: (): unknown => mockParseEvaluationArgs(),
argsToStageModels: (...args: unknown[]): unknown => mockArgsToStageModels(...args),
getDefaultDatasetName: (suite: unknown): unknown =>
suite === 'pairwise' ? 'notion-pairwise-workflows' : 'workflow-builder-canvas-prompts',
getDefaultExperimentName: (suite: unknown): unknown =>
suite === 'pairwise' ? 'pairwise-evals' : 'workflow-builder-evaluation',
}));
jest.mock('../support/environment', () => ({
setupTestEnvironment: (): unknown => mockSetupTestEnvironment(),
createAgent: (...args: unknown[]): unknown => mockCreateAgent(...args),
resolveNodesBasePath: (): string => '/mock/nodes-base',
}));
jest.mock('../langsmith/types', () => ({
generateRunId: (): unknown => mockGenerateRunId(),
isWorkflowStateValues: (...args: unknown[]): unknown => mockIsWorkflowStateValues(...args),
}));
jest.mock('../cli/csv-prompt-loader', () => ({
loadTestCasesFromCsv: (...args: unknown[]): unknown => mockLoadTestCasesFromCsv(...args),
loadDefaultTestCases: () => [
{ id: 'test-case-1', prompt: 'Create a workflow that sends a daily email summary' },
],
getDefaultTestCaseIds: () => ['test-case-1'],
}));
jest.mock('../cli/webhook', () => ({
sendWebhookNotification: (...args: unknown[]): unknown => mockSendWebhookNotification(...args),
}));
jest.mock('../harness/evaluation-helpers', () => ({
consumeGenerator: (...args: unknown[]): unknown => mockConsumeGenerator(...args),
getChatPayload: (...args: unknown[]): unknown => mockGetChatPayload(...args),
createWorkflowGenerator: () =>
jest.fn().mockResolvedValue({ name: 'Test', nodes: [], connections: {} }),
}));
jest.mock('../lifecycles/introspection-analysis', () => ({
createIntrospectionAnalysisLifecycle: () => ({}),
}));
jest.mock('../index', () => ({
runEvaluation: (...args: unknown[]): unknown => mockRunEvaluation(...args),
createConsoleLifecycle: (...args: unknown[]): unknown => mockCreateConsoleLifecycle(...args),
mergeLifecycles: (...lifecycles: unknown[]): unknown => {
// Simple merge implementation for tests - just return the first non-empty lifecycle
const valid = (lifecycles as Array<Record<string, unknown> | undefined>).filter(
(lc) => lc !== undefined,
);
if (valid.length === 0) return {};
// Return a merged object
return Object.assign({}, ...valid);
},
createLLMJudgeEvaluator: (...args: unknown[]): unknown => mockCreateLLMJudgeEvaluator(...args),
createProgrammaticEvaluator: (...args: unknown[]): unknown =>
mockCreateProgrammaticEvaluator(...args),
createPairwiseEvaluator: (...args: unknown[]): unknown => mockCreatePairwiseEvaluator(...args),
createSimilarityEvaluator: () => ({ name: 'similarity', evaluate: jest.fn() }),
createExecutionEvaluator: (...args: unknown[]): unknown => mockCreateExecutionEvaluator(...args),
}));
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
/** Helper to create default args */
function createMockArgs(overrides: Record<string, unknown> = {}) {
return {
suite: 'llm-judge',
backend: 'local',
verbose: false,
timeoutMs: 60_000,
datasetName: undefined,
prompt: undefined,
testCase: undefined,
promptsCsv: undefined,
maxExamples: undefined,
dos: undefined,
donts: undefined,
numJudges: 3,
experimentName: undefined,
repetitions: 1,
concurrency: 4,
featureFlags: undefined,
...overrides,
};
}
/** Helper to create mock environment */
function createMockEnvironment() {
const mockLlm = mock<BaseChatModel>();
return {
parsedNodeTypes: [] as INodeTypeDescription[],
llms: {
default: mockLlm,
supervisor: mockLlm,
responder: mockLlm,
discovery: mockLlm,
builder: mockLlm,
parameterUpdater: mockLlm,
judge: mockLlm,
},
lsClient: mock<Client>(),
};
}
/** Helper to create mock agent */
function createMockAgentInstance(workflowJSON: SimpleWorkflow = createMockWorkflow()) {
return {
chat: jest.fn().mockReturnValue((async function* () {})()),
getState: jest.fn().mockResolvedValue({
values: {
workflowJSON,
messages: [],
},
}),
};
}
/** Helper to create mock run summary */
function createMockSummary(overrides: Record<string, unknown> = {}) {
return {
totalExamples: 10,
passed: 8,
failed: 2,
errors: 0,
averageScore: 0.85,
totalDurationMs: 5000,
...overrides,
};
}
describe('CLI', () => {
// Mock process.exit to prevent test termination
let mockExit: jest.SpyInstance;
const originalEnv = process.env;
beforeEach(() => {
jest.clearAllMocks();
mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
// Reset environment
process.env = { ...originalEnv };
// Setup default mocks
mockParseEvaluationArgs.mockReturnValue(createMockArgs());
mockArgsToStageModels.mockReturnValue({ default: 'claude-sonnet-4.5' });
mockSetupTestEnvironment.mockResolvedValue(createMockEnvironment());
mockCreateAgent.mockReturnValue(createMockAgentInstance());
mockGenerateRunId.mockReturnValue('test-run-id');
mockIsWorkflowStateValues.mockReturnValue(true);
mockConsumeGenerator.mockResolvedValue(undefined);
mockGetChatPayload.mockReturnValue({});
mockRunEvaluation.mockResolvedValue(createMockSummary());
mockCreateConsoleLifecycle.mockReturnValue({});
mockCreateLLMJudgeEvaluator.mockReturnValue({ name: 'llm-judge', evaluate: jest.fn() });
mockCreateProgrammaticEvaluator.mockReturnValue({ name: 'programmatic', evaluate: jest.fn() });
mockCreatePairwiseEvaluator.mockReturnValue({ name: 'pairwise', evaluate: jest.fn() });
mockCreateExecutionEvaluator.mockReturnValue({ name: 'execution', evaluate: jest.fn() });
});
afterEach(() => {
mockExit.mockRestore();
process.env = originalEnv;
});
describe('runV2Evaluation()', () => {
describe('loadTestCases', () => {
it('should load test cases from CSV when promptsCsv is set', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ promptsCsv: '/path/to/prompts.csv' }),
);
mockLoadTestCasesFromCsv.mockReturnValue([
{ prompt: 'CSV prompt 1', id: '1' },
{ prompt: 'CSV prompt 2', id: '2' },
]);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockLoadTestCasesFromCsv).toHaveBeenCalledWith('/path/to/prompts.csv');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
dataset: [
{ prompt: 'CSV prompt 1', id: '1' },
{ prompt: 'CSV prompt 2', id: '2' },
],
}),
);
});
it('should create single test case when prompt is set', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({
prompt: 'Create a workflow',
dos: 'Use Slack',
donts: 'No HTTP',
}),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
dataset: [
{
prompt: 'Create a workflow',
context: { dos: 'Use Slack', donts: 'No HTTP' },
},
],
}),
);
});
it('should use default test case when no prompt source specified', async () => {
mockParseEvaluationArgs.mockReturnValue(createMockArgs());
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
dataset: [
{ id: 'test-case-1', prompt: 'Create a workflow that sends a daily email summary' },
],
}),
);
});
});
describe('mode selection', () => {
it('should create LLM-judge + programmatic evaluators for llm-judge suite (local)', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ suite: 'llm-judge', backend: 'local' }),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockCreateLLMJudgeEvaluator).toHaveBeenCalled();
expect(mockCreateProgrammaticEvaluator).toHaveBeenCalled();
expect(mockCreatePairwiseEvaluator).not.toHaveBeenCalled();
});
it('should create LLM-judge + programmatic evaluators for llm-judge suite (langsmith)', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ suite: 'llm-judge', backend: 'langsmith' }),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockCreateLLMJudgeEvaluator).toHaveBeenCalled();
expect(mockCreateProgrammaticEvaluator).toHaveBeenCalled();
expect(mockCreatePairwiseEvaluator).not.toHaveBeenCalled();
});
it('should create pairwise + programmatic evaluators for pairwise suite (local)', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ suite: 'pairwise', backend: 'local', numJudges: 5 }),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockCreatePairwiseEvaluator).toHaveBeenCalled();
// Verify numJudges was passed correctly
const callArgs = mockCreatePairwiseEvaluator.mock.calls[0] as [
unknown,
{ numJudges: number },
];
expect(callArgs[1]).toEqual({ numJudges: 5 });
expect(mockCreateProgrammaticEvaluator).toHaveBeenCalled();
expect(mockCreateLLMJudgeEvaluator).not.toHaveBeenCalled();
});
it('should create pairwise + programmatic evaluators for pairwise suite (langsmith)', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ suite: 'pairwise', backend: 'langsmith' }),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockCreatePairwiseEvaluator).toHaveBeenCalled();
expect(mockCreateProgrammaticEvaluator).toHaveBeenCalled();
expect(mockCreateLLMJudgeEvaluator).not.toHaveBeenCalled();
});
});
describe('config building', () => {
it('should use local mode for backend=local', async () => {
mockParseEvaluationArgs.mockReturnValue(createMockArgs({ backend: 'local' }));
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'local',
}),
);
});
it('should use langsmith mode for backend=langsmith', async () => {
mockParseEvaluationArgs.mockReturnValue(createMockArgs({ backend: 'langsmith' }));
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'langsmith',
langsmithOptions: expect.objectContaining({
experimentName: 'workflow-builder-evaluation',
}),
}),
);
});
it('should use datasetName from args for langsmith mode', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ backend: 'langsmith', datasetName: 'custom-dataset' }),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
dataset: 'custom-dataset',
}),
);
});
it('should fall back to default dataset name when env var not set', async () => {
delete process.env.LANGSMITH_DATASET_NAME;
mockParseEvaluationArgs.mockReturnValue(createMockArgs({ backend: 'langsmith' }));
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
dataset: 'workflow-builder-canvas-prompts',
}),
);
});
it('should include langsmithOptions with custom experiment name', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({
backend: 'langsmith',
experimentName: 'my-experiment',
repetitions: 3,
concurrency: 8,
}),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
langsmithOptions: expect.objectContaining({
experimentName: 'my-experiment',
repetitions: 3,
concurrency: 8,
}),
}),
);
});
});
describe('exit codes', () => {
it('should always exit with 0 on successful completion (pass/fail is informational)', async () => {
mockRunEvaluation.mockResolvedValue(createMockSummary({ totalExamples: 10, passed: 7 }));
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
});
it('should exit with 0 even when pass rate is low', async () => {
mockRunEvaluation.mockResolvedValue(createMockSummary({ totalExamples: 10, passed: 5 }));
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
});
it('should exit with 0 even when no examples', async () => {
mockRunEvaluation.mockResolvedValue(createMockSummary({ totalExamples: 0, passed: 0 }));
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
});
});
describe('workflow generator', () => {
it('should create agent with correct config', async () => {
const env = createMockEnvironment();
mockSetupTestEnvironment.mockResolvedValue(env);
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ featureFlags: { testFlag: true } }),
);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
// Verify generateWorkflow was passed to config
expect(mockRunEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
generateWorkflow: expect.any(Function),
}),
);
});
it('should setup test environment', async () => {
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockSetupTestEnvironment).toHaveBeenCalled();
});
it('should create console lifecycle with verbose option', async () => {
mockParseEvaluationArgs.mockReturnValue(createMockArgs({ verbose: true }));
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit');
expect(mockCreateConsoleLifecycle).toHaveBeenCalledWith(
expect.objectContaining({ verbose: true }),
);
});
});
describe('webhook notification', () => {
it('should send webhook notification when webhookUrl is provided', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({
webhookUrl: 'https://example.com/webhook',
suite: 'llm-judge',
backend: 'local',
}),
);
mockRunEvaluation.mockResolvedValue(createMockSummary());
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
expect(mockSendWebhookNotification).toHaveBeenCalledTimes(1);
expect(mockSendWebhookNotification).toHaveBeenCalledWith(
expect.objectContaining({
webhookUrl: 'https://example.com/webhook',
suite: 'llm-judge',
dataset: 'local-dataset',
}),
);
});
it('should NOT send webhook notification when webhookUrl is not provided', async () => {
mockParseEvaluationArgs.mockReturnValue(createMockArgs({ webhookUrl: undefined }));
mockRunEvaluation.mockResolvedValue(createMockSummary());
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
expect(mockSendWebhookNotification).not.toHaveBeenCalled();
});
it('should use dataset name from args for langsmith backend', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({
webhookUrl: 'https://example.com/webhook',
backend: 'langsmith',
experimentName: 'my-custom-experiment',
datasetName: 'my-custom-dataset',
suite: 'pairwise',
}),
);
mockRunEvaluation.mockResolvedValue(createMockSummary());
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
expect(mockSendWebhookNotification).toHaveBeenCalledWith(
expect.objectContaining({
dataset: 'my-custom-dataset',
suite: 'pairwise',
}),
);
});
it('should use default dataset name for langsmith backend when not specified', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({
webhookUrl: 'https://example.com/webhook',
backend: 'langsmith',
experimentName: undefined,
datasetName: undefined,
suite: 'llm-judge',
}),
);
mockRunEvaluation.mockResolvedValue(createMockSummary());
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
expect(mockSendWebhookNotification).toHaveBeenCalledWith(
expect.objectContaining({
dataset: 'workflow-builder-canvas-prompts',
}),
);
});
it('should pass run summary to webhook notification', async () => {
const summary = createMockSummary({
totalExamples: 25,
passed: 20,
failed: 5,
errors: 0,
averageScore: 0.92,
totalDurationMs: 12000,
});
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ webhookUrl: 'https://example.com/webhook' }),
);
mockRunEvaluation.mockResolvedValue(summary);
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
expect(mockSendWebhookNotification).toHaveBeenCalledWith(
expect.objectContaining({
summary,
}),
);
});
it('should include CI metadata in webhook notification', async () => {
mockParseEvaluationArgs.mockReturnValue(
createMockArgs({ webhookUrl: 'https://example.com/webhook' }),
);
mockRunEvaluation.mockResolvedValue(createMockSummary());
const { runV2Evaluation } = await import('../cli');
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
expect(mockSendWebhookNotification).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.any(Object),
}),
);
});
});
});
});