first commit
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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,182 @@
import { parseEvaluationArgs, type EvaluationSuite } from '../cli/argument-parser';
describe('argument-parser', () => {
describe('suite options', () => {
it('accepts all valid suite options', () => {
const validSuites: EvaluationSuite[] = [
'llm-judge',
'pairwise',
'programmatic',
'similarity',
];
for (const suite of validSuites) {
const args = parseEvaluationArgs(['--suite', suite]);
expect(args.suite).toBe(suite);
}
});
});
it('parses numeric flags like --max-examples and --concurrency', () => {
const args = parseEvaluationArgs([
'--suite',
'pairwise',
'--backend',
'langsmith',
'--max-examples',
'5',
'--concurrency',
'3',
]);
expect(args.maxExamples).toBe(5);
expect(args.concurrency).toBe(3);
});
it('supports inline --max-examples= syntax', () => {
const args = parseEvaluationArgs(['--max-examples=7']);
expect(args.maxExamples).toBe(7);
});
it('parses filters for pairwise suite', () => {
const args = parseEvaluationArgs([
'--suite',
'pairwise',
'--backend',
'langsmith',
'--filter',
'do:Slack',
'--filter',
'technique:content_generation',
]);
expect(args.filters).toEqual({
doSearch: 'Slack',
technique: 'content_generation',
});
});
it('accepts prompt values that start with "-"', () => {
const args = parseEvaluationArgs(['--prompt', '-starts-with-dash']);
expect(args.prompt).toBe('-starts-with-dash');
});
it('rejects conflicting backend/local when --langsmith is set', () => {
expect(() => parseEvaluationArgs(['--langsmith', '--backend', 'local'])).toThrow(
'Cannot combine `--langsmith` with `--backend local`',
);
});
it('treats --langsmith as backend=langsmith', () => {
const args = parseEvaluationArgs(['--langsmith']);
expect(args.backend).toBe('langsmith');
});
it('rejects do/dont filters for non-pairwise suite', () => {
expect(() => parseEvaluationArgs(['--suite', 'llm-judge', '--filter', 'do:Slack'])).toThrow(
'only supported for `--suite pairwise`',
);
});
it('rejects malformed filters', () => {
expect(() =>
parseEvaluationArgs(['--suite', 'pairwise', '--backend', 'langsmith', '--filter', 'nope']),
).toThrow('Invalid `--filter` format');
});
describe('--webhook-url', () => {
it('parses valid HTTPS webhook URL', () => {
const args = parseEvaluationArgs(['--webhook-url', 'https://example.com/webhook']);
expect(args.webhookUrl).toBe('https://example.com/webhook');
});
it('parses webhook URL with inline = syntax', () => {
const args = parseEvaluationArgs(['--webhook-url=https://api.example.com/hook']);
expect(args.webhookUrl).toBe('https://api.example.com/hook');
});
it('rejects invalid URL format', () => {
expect(() => parseEvaluationArgs(['--webhook-url', 'not-a-url'])).toThrow();
});
it('rejects non-URL strings', () => {
expect(() => parseEvaluationArgs(['--webhook-url', 'just-some-text'])).toThrow();
});
it('allows webhook URL to be undefined when not provided', () => {
const args = parseEvaluationArgs([]);
expect(args.webhookUrl).toBeUndefined();
});
it('parses webhook URL with path and query params', () => {
const args = parseEvaluationArgs([
'--webhook-url',
'https://hooks.example.com/api/v1/notify?token=abc123',
]);
expect(args.webhookUrl).toBe('https://hooks.example.com/api/v1/notify?token=abc123');
});
});
it('accepts binary-checks suite', () => {
const args = parseEvaluationArgs(['--suite', 'binary-checks']);
expect(args.suite).toBe('binary-checks');
});
it('parses --checks flag as comma-separated list', () => {
const args = parseEvaluationArgs([
'--suite',
'binary-checks',
'--checks',
'has_nodes,has_trigger,all_nodes_connected',
]);
expect(args.checks).toEqual(['has_nodes', 'has_trigger', 'all_nodes_connected']);
});
it('trims whitespace in --checks values', () => {
const args = parseEvaluationArgs([
'--suite',
'binary-checks',
'--checks',
' has_nodes , has_trigger ',
]);
expect(args.checks).toEqual(['has_nodes', 'has_trigger']);
});
it('throws when --checks is used with non-binary-checks suite', () => {
expect(() => parseEvaluationArgs(['--suite', 'llm-judge', '--checks', 'has_nodes'])).toThrow(
'`--checks` is only supported for `--suite binary-checks`',
);
});
describe('--webhook-secret', () => {
it('parses valid webhook secret', () => {
const args = parseEvaluationArgs(['--webhook-secret', 'my-secure-secret-key-1234567890']);
expect(args.webhookSecret).toBe('my-secure-secret-key-1234567890');
});
it('parses webhook secret with inline = syntax', () => {
const args = parseEvaluationArgs(['--webhook-secret=another-secret-key-12345678']);
expect(args.webhookSecret).toBe('another-secret-key-12345678');
});
it('rejects secret shorter than 16 characters', () => {
expect(() => parseEvaluationArgs(['--webhook-secret', 'short'])).toThrow();
});
it('allows webhook secret to be undefined when not provided', () => {
const args = parseEvaluationArgs([]);
expect(args.webhookSecret).toBeUndefined();
});
it('can be combined with webhook URL', () => {
const args = parseEvaluationArgs([
'--webhook-url',
'https://example.com/webhook',
'--webhook-secret',
'my-secure-secret-key-1234567890',
]);
expect(args.webhookUrl).toBe('https://example.com/webhook');
expect(args.webhookSecret).toBe('my-secure-secret-key-1234567890');
});
});
});
@@ -0,0 +1,619 @@
/**
* 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),
}),
);
});
});
});
});
@@ -0,0 +1,174 @@
/**
* Tests for CSV prompt loader.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadTestCasesFromCsv } from '../cli/csv-prompt-loader';
describe('csv-prompt-loader', () => {
const tempDirs: string[] = [];
function writeTempCsv(filename: string, content: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'eval-csv-'));
tempDirs.push(dir);
const csvPath = path.join(dir, filename);
fs.writeFileSync(csvPath, content, 'utf8');
return csvPath;
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir && fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
it('should load dos/donts into context when present', () => {
const csvPath = writeTempCsv(
'pairwise.csv',
'id,prompt,dos,donts\npw-1,"Create a workflow","Must use Notion","No HTTP Request"\n',
);
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([
{
id: 'pw-1',
prompt: 'Create a workflow',
context: { dos: 'Must use Notion', donts: 'No HTTP Request' },
},
]);
});
it('should load prompts without context when dos/donts are absent', () => {
const csvPath = writeTempCsv('llm.csv', 'id,prompt\nllm-1,"Create a workflow"\n');
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([{ id: 'llm-1', prompt: 'Create a workflow' }]);
});
it('should support header column re-ordering', () => {
const csvPath = writeTempCsv(
'reorder.csv',
'prompt,id,donts,dos\n"Create a workflow","pw-1","No HTTP Request","Must use Notion"\n',
);
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([
{
id: 'pw-1',
prompt: 'Create a workflow',
context: { dos: 'Must use Notion', donts: 'No HTTP Request' },
},
]);
});
it('should support do/dont header aliases', () => {
const csvPath = writeTempCsv(
'aliases.csv',
'id,prompt,do,dont\npw-1,"Create a workflow","Must use Notion","No HTTP Request"\n',
);
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([
{
id: 'pw-1',
prompt: 'Create a workflow',
context: { dos: 'Must use Notion', donts: 'No HTTP Request' },
},
]);
});
it('should not create context when dos/donts columns are present but empty', () => {
const csvPath = writeTempCsv(
'empty-context.csv',
'id,prompt,dos,donts\npw-1,"Create a workflow",,\n',
);
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([{ id: 'pw-1', prompt: 'Create a workflow' }]);
});
it('should allow headerless CSV (treat first column as prompt)', () => {
const csvPath = writeTempCsv('no-header.csv', '"Create a workflow"\n"Second prompt"\n');
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([
{ id: 'csv-case-1', prompt: 'Create a workflow' },
{ id: 'csv-case-2', prompt: 'Second prompt' },
]);
});
it('should ignore rows with empty prompts', () => {
const csvPath = writeTempCsv('empty-rows.csv', 'id,prompt\nrow-1,\nrow-2,"Valid prompt"\n');
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([{ id: 'row-2', prompt: 'Valid prompt' }]);
});
it('should handle UTF-8 BOM', () => {
const csvPath = writeTempCsv('bom.csv', '\ufeffid,prompt\nllm-1,"Create a workflow"\n');
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([{ id: 'llm-1', prompt: 'Create a workflow' }]);
});
it('should resolve relative paths from process.cwd()', () => {
const csvPath = writeTempCsv('relative.csv', 'id,prompt\nllm-1,"Create a workflow"\n');
const relativePath = path.relative(process.cwd(), csvPath);
const testCases = loadTestCasesFromCsv(relativePath);
expect(testCases).toEqual([{ id: 'llm-1', prompt: 'Create a workflow' }]);
});
it('should throw when file does not exist', () => {
expect(() => loadTestCasesFromCsv('/definitely-not-a-real-path.csv')).toThrow(
/CSV file not found/,
);
});
it('should throw when CSV is empty', () => {
const csvPath = writeTempCsv('empty.csv', '');
expect(() => loadTestCasesFromCsv(csvPath)).toThrow('The provided CSV file is empty');
});
it('should throw when no valid prompts exist', () => {
const csvPath = writeTempCsv('no-prompts.csv', 'id,prompt\nrow-1,\nrow-2," "\n');
expect(() => loadTestCasesFromCsv(csvPath)).toThrow(
'No valid prompts found in the provided CSV file',
);
});
it('should parse annotations column as JSON into context', () => {
const csvPath = writeTempCsv(
'annotations.csv',
'id,prompt,annotations\nann-1,"Create a workflow","{""code_necessary"":true}"\n',
);
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([
{
id: 'ann-1',
prompt: 'Create a workflow',
context: { annotations: { code_necessary: true } },
},
]);
});
it('should skip invalid JSON in annotations column with warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
const csvPath = writeTempCsv(
'bad-annotations.csv',
'id,prompt,annotations\nbad-1,"Create a workflow","not-json"\n',
);
const testCases = loadTestCasesFromCsv(csvPath);
expect(testCases).toEqual([{ id: 'bad-1', prompt: 'Create a workflow', context: {} }]);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON'));
warnSpy.mockRestore();
});
});
@@ -0,0 +1,143 @@
/**
* Tests for evaluation helper utilities.
*/
import pLimit from 'p-limit';
import { withTimeout, extractSubgraphMetrics } from '../harness/evaluation-helpers';
describe('evaluation-helpers', () => {
describe('withTimeout()', () => {
it('should allow p-limit slot to be released when timeout triggers (best-effort)', async () => {
jest.useFakeTimers();
const limit = pLimit(1);
const started: string[] = [];
const never = new Promise<void>(() => {
// never resolves
});
const p1 = limit(async () => {
started.push('p1');
await withTimeout({ promise: never, timeoutMs: 10, label: 'p1' });
}).catch(() => {
// expected timeout
});
// Give p1 a chance to start.
await Promise.resolve();
const p2 = limit(async () => {
started.push('p2');
});
jest.advanceTimersByTime(11);
await Promise.resolve();
await Promise.resolve();
await expect(p2).resolves.toBeUndefined();
expect(started).toEqual(['p1', 'p2']);
await p1;
jest.useRealTimers();
});
});
describe('extractSubgraphMetrics()', () => {
it('should return empty object when both inputs are undefined', () => {
const result = extractSubgraphMetrics(undefined, undefined);
expect(result).toEqual({});
});
it.each([
{ nodeCount: 5, expected: { nodeCount: 5 } },
{ nodeCount: 0, expected: { nodeCount: 0 } },
])('should include nodeCount of $nodeCount when provided', ({ nodeCount, expected }) => {
const result = extractSubgraphMetrics(undefined, nodeCount);
expect(result).toEqual(expected);
});
it.each([
{
phase: 'discovery' as const,
metricKey: 'discoveryDurationMs',
startTs: 1000,
endTs: 1500,
expectedDuration: 500,
},
{
phase: 'builder' as const,
metricKey: 'builderDurationMs',
startTs: 2000,
endTs: 3500,
expectedDuration: 1500,
},
{
phase: 'responder' as const,
metricKey: 'responderDurationMs',
startTs: 5000,
endTs: 5800,
expectedDuration: 800,
},
])(
'should calculate $phase duration from in_progress to completed',
({ phase, metricKey, startTs, endTs, expectedDuration }) => {
const coordinationLog = [
{ phase, status: 'in_progress' as const, timestamp: startTs },
{ phase, status: 'completed' as const, timestamp: endTs },
];
const result = extractSubgraphMetrics(coordinationLog, undefined);
expect(result).toEqual({ [metricKey]: expectedDuration });
},
);
it('should calculate duration from first to last entry when no in_progress status', () => {
const coordinationLog = [
{ phase: 'discovery' as const, status: 'completed' as const, timestamp: 1000 },
{ phase: 'discovery' as const, status: 'completed' as const, timestamp: 1800 },
];
const result = extractSubgraphMetrics(coordinationLog, undefined);
expect(result).toEqual({ discoveryDurationMs: 800 });
});
it('should ignore state_management phase', () => {
const coordinationLog = [
{ phase: 'state_management' as const, status: 'in_progress' as const, timestamp: 500 },
{ phase: 'state_management' as const, status: 'completed' as const, timestamp: 600 },
];
const result = extractSubgraphMetrics(coordinationLog, undefined);
expect(result).toEqual({});
});
it('should return undefined for phase with only one entry', () => {
const coordinationLog = [
{ phase: 'discovery' as const, status: 'completed' as const, timestamp: 1000 },
];
const result = extractSubgraphMetrics(coordinationLog, undefined);
expect(result).toEqual({});
});
it('should return empty object for empty coordination log', () => {
const result = extractSubgraphMetrics([], undefined);
expect(result).toEqual({});
});
it('should calculate all phase durations together with nodeCount', () => {
const coordinationLog = [
{ phase: 'discovery' as const, status: 'in_progress' as const, timestamp: 1000 },
{ phase: 'discovery' as const, status: 'completed' as const, timestamp: 1500 },
{ phase: 'builder' as const, status: 'in_progress' as const, timestamp: 2000 },
{ phase: 'builder' as const, status: 'completed' as const, timestamp: 4000 },
{ phase: 'responder' as const, status: 'in_progress' as const, timestamp: 4500 },
{ phase: 'responder' as const, status: 'completed' as const, timestamp: 5000 },
];
const result = extractSubgraphMetrics(coordinationLog, 8);
expect(result).toEqual({
nodeCount: 8,
discoveryDurationMs: 500,
builderDurationMs: 2000,
responderDurationMs: 500,
});
});
});
});
@@ -0,0 +1,192 @@
/**
* Tests for LLM-as-judge evaluator factory.
*
* These tests mock the underlying evaluateWorkflow function and verify
* that the factory correctly wraps it and transforms the results.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types/workflow';
// Store original module
const mockEvaluateWorkflow = jest.fn();
// Mock the evaluateWorkflow function
jest.mock('../../evaluators/llm-judge/workflow-evaluator', () => ({
evaluateWorkflow: (...args: unknown[]): unknown => mockEvaluateWorkflow(...args),
}));
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
/** Helper to create a mock evaluation result */
function createMockEvalResult(overrides: Record<string, unknown> = {}) {
return {
functionality: { score: 0.9, violations: [] },
connections: { score: 1.0, violations: [] },
expressions: { score: 0.8, violations: [] },
nodeConfiguration: { score: 0.85, violations: [] },
efficiency: {
score: 0.95,
violations: [],
redundancyScore: 1,
pathOptimization: 0.9,
nodeCountEfficiency: 0.95,
},
dataFlow: { score: 0.9, violations: [] },
maintainability: {
score: 0.88,
violations: [],
nodeNamingQuality: 0.9,
workflowOrganization: 0.85,
modularity: 0.9,
},
bestPractices: { score: 0.82, violations: [] },
overallScore: 0.9,
summary: 'Good workflow',
...overrides,
};
}
describe('LLM-Judge Evaluator', () => {
let mockLlm: BaseChatModel;
let mockNodeTypes: INodeTypeDescription[];
beforeEach(() => {
jest.clearAllMocks();
mockLlm = mock<BaseChatModel>();
mockNodeTypes = [];
});
describe('createLLMJudgeEvaluator()', () => {
it('should create an evaluator with correct name', async () => {
const { createLLMJudgeEvaluator } = await import('../../evaluators/llm-judge');
const evaluator = createLLMJudgeEvaluator(mockLlm, mockNodeTypes);
expect(evaluator.name).toBe('llm-judge');
});
it('should call evaluateWorkflow with workflow and prompt', async () => {
mockEvaluateWorkflow.mockResolvedValue(createMockEvalResult());
const { createLLMJudgeEvaluator } = await import('../../evaluators/llm-judge');
const evaluator = createLLMJudgeEvaluator(mockLlm, mockNodeTypes);
const workflow = createMockWorkflow();
const context = { prompt: 'Create a test workflow' };
await evaluator.evaluate(workflow, context);
expect(mockEvaluateWorkflow).toHaveBeenCalledWith(mockLlm, {
userPrompt: 'Create a test workflow',
generatedWorkflow: workflow,
});
});
it('should return feedback array with all category scores', async () => {
mockEvaluateWorkflow.mockResolvedValue(createMockEvalResult());
const { createLLMJudgeEvaluator } = await import('../../evaluators/llm-judge');
const evaluator = createLLMJudgeEvaluator(mockLlm, mockNodeTypes);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test' });
// Should have feedback for each category
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'functionality' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'connections' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'expressions' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'nodeConfiguration' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'efficiency' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'dataFlow' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'maintainability' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'bestPractices' }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'llm-judge', metric: 'overallScore' }),
);
});
it('should include violations in feedback comments', async () => {
mockEvaluateWorkflow.mockResolvedValue(
createMockEvalResult({
functionality: {
score: 0.5,
violations: [
{ type: 'critical', description: 'Missing HTTP node', pointsDeducted: 0.3 },
{ type: 'major', description: 'Incorrect branching', pointsDeducted: 0.2 },
],
},
}),
);
const { createLLMJudgeEvaluator } = await import('../../evaluators/llm-judge');
const evaluator = createLLMJudgeEvaluator(mockLlm, mockNodeTypes);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test' });
const funcFeedback = feedback.find(
(f) => f.evaluator === 'llm-judge' && f.metric === 'functionality',
);
expect(funcFeedback?.comment).toContain('Missing HTTP node');
});
it('should include bestPractices violations in comments', async () => {
mockEvaluateWorkflow.mockResolvedValue(
createMockEvalResult({
bestPractices: {
score: 0.4,
violations: [
{ type: 'major', description: 'Missing rate limiting', pointsDeducted: 0.2 },
],
},
}),
);
const { createLLMJudgeEvaluator } = await import('../../evaluators/llm-judge');
const evaluator = createLLMJudgeEvaluator(mockLlm, mockNodeTypes);
const feedback = await evaluator.evaluate(createMockWorkflow(), { prompt: 'Test' });
const bpFeedback = feedback.find(
(f) => f.evaluator === 'llm-judge' && f.metric === 'bestPractices',
);
expect(bpFeedback?.comment).toContain('Missing rate limiting');
});
it('should handle evaluation errors gracefully', async () => {
mockEvaluateWorkflow.mockRejectedValue(new Error('LLM API error'));
const { createLLMJudgeEvaluator } = await import('../../evaluators/llm-judge');
const evaluator = createLLMJudgeEvaluator(mockLlm, mockNodeTypes);
const workflow = createMockWorkflow();
// Should throw - let the runner handle errors
await expect(evaluator.evaluate(workflow, { prompt: 'Test' })).rejects.toThrow(
'LLM API error',
);
});
});
});
@@ -0,0 +1,290 @@
/**
* Tests for pairwise evaluator factory.
*
* These tests mock the underlying runJudgePanel function and verify
* that the factory correctly wraps it and transforms the results.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import type { SimpleWorkflow } from '@/types/workflow';
import { PAIRWISE_METRICS } from '../../evaluators/pairwise/metrics';
// Store mock for runJudgePanel
const mockRunJudgePanel = jest.fn();
// Mock the judge panel module
jest.mock('../../evaluators/pairwise/judge-panel', () => ({
runJudgePanel: (...args: unknown[]): unknown => mockRunJudgePanel(...args),
}));
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
/** Helper to create a mock judge panel result */
function createMockPanelResult(
overrides: Partial<{
primaryPasses: number;
majorityPass: boolean;
avgDiagnosticScore: number;
judgeResults: Array<{
primaryPass: boolean;
diagnosticScore: number;
violations: Array<{ rule: string; justification: string }>;
passes: Array<{ rule: string; justification: string }>;
}>;
}> = {},
) {
return {
primaryPasses: 2,
majorityPass: true,
avgDiagnosticScore: 0.8,
judgeResults: [
{
primaryPass: true,
diagnosticScore: 0.9,
violations: [],
passes: [{ rule: 'Has trigger', justification: 'Gmail trigger exists' }],
},
{
primaryPass: true,
diagnosticScore: 0.8,
violations: [],
passes: [{ rule: 'Has trigger', justification: 'Trigger present' }],
},
{
primaryPass: false,
diagnosticScore: 0.7,
violations: [{ rule: 'Missing action', justification: 'No Slack node found' }],
passes: [],
},
],
...overrides,
};
}
describe('Pairwise Evaluator', () => {
let mockLlm: BaseChatModel;
type PairwiseFeedback = { evaluator: string; metric: string; score: number; comment?: string };
const findFeedback = (feedback: PairwiseFeedback[], metric: string) =>
feedback.find((f) => f.evaluator === 'pairwise' && f.metric === metric);
beforeEach(() => {
jest.clearAllMocks();
mockLlm = mock<BaseChatModel>();
});
describe('createPairwiseEvaluator()', () => {
it('should create an evaluator with correct name', async () => {
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
expect(evaluator.name).toBe('pairwise');
});
it('should call runJudgePanel with workflow and criteria', async () => {
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
const context = { prompt: 'Test prompt', dos: 'Use Slack', donts: 'No HTTP requests' };
await evaluator.evaluate(workflow, context);
expect(mockRunJudgePanel).toHaveBeenCalledWith(
mockLlm,
workflow,
{ dos: 'Use Slack', donts: 'No HTTP requests' },
3, // default number of judges
expect.any(Object),
);
});
it('should use custom number of judges', async () => {
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm, { numJudges: 5 });
const workflow = createMockWorkflow();
await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
expect(mockRunJudgePanel).toHaveBeenCalledWith(
mockLlm,
workflow,
expect.any(Object),
5,
expect.any(Object),
);
});
it('should pass through empty criteria when context has no dos/donts', async () => {
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
expect(mockRunJudgePanel).toHaveBeenCalledWith(
mockLlm,
workflow,
{
dos: undefined,
donts: undefined,
},
3,
expect.any(Object),
);
});
it('should return feedback with majority pass result', async () => {
mockRunJudgePanel.mockResolvedValue(
createMockPanelResult({ majorityPass: true, primaryPasses: 2 }),
);
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
const majorityFeedback = findFeedback(feedback, PAIRWISE_METRICS.PAIRWISE_PRIMARY);
expect(majorityFeedback).toEqual({
evaluator: 'pairwise',
metric: PAIRWISE_METRICS.PAIRWISE_PRIMARY,
score: 1,
kind: 'score',
comment: '2/3 judges passed',
});
});
it('should return feedback with diagnostic score', async () => {
mockRunJudgePanel.mockResolvedValue(createMockPanelResult({ avgDiagnosticScore: 0.85 }));
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
const diagnosticFeedback = findFeedback(feedback, PAIRWISE_METRICS.PAIRWISE_DIAGNOSTIC);
expect(diagnosticFeedback).toEqual({
evaluator: 'pairwise',
metric: PAIRWISE_METRICS.PAIRWISE_DIAGNOSTIC,
score: 0.85,
kind: 'metric',
});
});
it('should return feedback for each judge', async () => {
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'pairwise', metric: 'judge1', score: 1 }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'pairwise', metric: 'judge2', score: 1 }),
);
expect(feedback).toContainEqual(
expect.objectContaining({ evaluator: 'pairwise', metric: 'judge3', score: 0 }),
);
});
it('should include violations in judge feedback comments', async () => {
mockRunJudgePanel.mockResolvedValue(
createMockPanelResult({
judgeResults: [
{
primaryPass: false,
diagnosticScore: 0.5,
violations: [
{ rule: 'Has Slack', justification: 'Missing Slack node for notifications' },
{ rule: 'Has trigger', justification: 'No trigger node found' },
],
passes: [],
},
],
}),
);
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
const judgeFeedback = findFeedback(feedback, 'judge1');
// Full violation output without truncation
expect(judgeFeedback?.comment).toContain('[Has Slack] Missing Slack node for notifications');
expect(judgeFeedback?.comment).toContain('[Has trigger] No trigger node found');
});
it('should handle evaluation errors gracefully', async () => {
mockRunJudgePanel.mockRejectedValue(new Error('Judge panel failed'));
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
// Should throw - let the runner handle errors
await expect(evaluator.evaluate(workflow, { prompt: 'Test prompt' })).rejects.toThrow(
'Judge panel failed',
);
});
it('should accept criteria with only dos (no donts)', async () => {
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
const context = { prompt: 'Test prompt', dos: 'Use Slack node' };
await evaluator.evaluate(workflow, context);
expect(mockRunJudgePanel).toHaveBeenCalledWith(
mockLlm,
workflow,
{ dos: 'Use Slack node', donts: undefined },
3,
expect.any(Object),
);
});
it('should accept criteria with only donts (no dos)', async () => {
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
const evaluator = createPairwiseEvaluator(mockLlm);
const workflow = createMockWorkflow();
const context = { prompt: 'Test prompt', donts: 'Do not use HTTP Request node' };
await evaluator.evaluate(workflow, context);
expect(mockRunJudgePanel).toHaveBeenCalledWith(
mockLlm,
workflow,
{ dos: undefined, donts: 'Do not use HTTP Request node' },
3,
expect.any(Object),
);
});
});
});
@@ -0,0 +1,267 @@
/**
* Tests for programmatic evaluator factory.
*
* These tests mock the underlying programmaticEvaluation function and verify
* that the factory correctly wraps it and transforms the results.
*/
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types/workflow';
// Store mock for programmaticEvaluation
const mockProgrammaticEvaluation = jest.fn();
// Mock the programmatic evaluation module
jest.mock('../../programmatic/programmatic-evaluation', () => ({
programmaticEvaluation: (...args: unknown[]): unknown => mockProgrammaticEvaluation(...args),
}));
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
/** Helper to create mock evaluation result */
function createMockEvaluationResult(
overrides: Partial<{
overallScore: number;
connections: { score: number; violations: Array<{ type: string; description: string }> };
nodes: { score: number; violations: Array<{ type: string; description: string }> };
trigger: { score: number; violations: Array<{ type: string; description: string }> };
agentPrompt: { score: number; violations: Array<{ type: string; description: string }> };
tools: { score: number; violations: Array<{ type: string; description: string }> };
fromAi: { score: number; violations: Array<{ type: string; description: string }> };
credentials: { score: number; violations: Array<{ type: string; description: string }> };
graphValidation: { score: number; violations: Array<{ type: string; description: string }> };
parameters: { score: number; violations: Array<{ type: string; description: string }> };
similarity: { score: number; violations: Array<{ type: string; description: string }> } | null;
}> = {},
) {
return {
overallScore: 0.85,
connections: { score: 1.0, violations: [] },
nodes: { score: 1.0, violations: [] },
trigger: { score: 1.0, violations: [] },
agentPrompt: { score: 0.9, violations: [] },
tools: { score: 1.0, violations: [] },
fromAi: { score: 0.8, violations: [] },
credentials: { score: 1.0, violations: [] },
graphValidation: { score: 1.0, violations: [] },
parameters: { score: 1.0, violations: [] },
similarity: null,
...overrides,
};
}
describe('Programmatic Evaluator', () => {
const mockNodeTypes: INodeTypeDescription[] = [];
type ProgrammaticFeedback = {
evaluator: string;
metric: string;
score: number;
comment?: string;
};
const findFeedback = (feedback: ProgrammaticFeedback[], metric: string) =>
feedback.find((f) => f.evaluator === 'programmatic' && f.metric === metric);
beforeEach(() => {
jest.clearAllMocks();
});
describe('createProgrammaticEvaluator()', () => {
it('should create an evaluator with correct name', async () => {
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
expect(evaluator.name).toBe('programmatic');
});
it('should call programmaticEvaluation with workflow and context', async () => {
mockProgrammaticEvaluation.mockResolvedValue(createMockEvaluationResult());
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const context = { prompt: 'Create a test workflow' };
await evaluator.evaluate(workflow, context);
expect(mockProgrammaticEvaluation).toHaveBeenCalledWith(
{
userPrompt: 'Create a test workflow',
generatedWorkflow: workflow,
referenceWorkflows: undefined,
},
mockNodeTypes,
);
});
it('should pass reference workflows when provided in context', async () => {
mockProgrammaticEvaluation.mockResolvedValue(createMockEvaluationResult());
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
const context = { prompt: 'Test prompt', referenceWorkflows: [referenceWorkflow] };
await evaluator.evaluate(workflow, context);
expect(mockProgrammaticEvaluation).toHaveBeenCalledWith(
{
userPrompt: 'Test prompt',
generatedWorkflow: workflow,
referenceWorkflows: [referenceWorkflow],
},
mockNodeTypes,
);
});
it('should return feedback with overall score', async () => {
mockProgrammaticEvaluation.mockResolvedValue(
createMockEvaluationResult({ overallScore: 0.92 }),
);
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test' });
const overallFeedback = findFeedback(feedback, 'overall');
expect(overallFeedback).toEqual({
evaluator: 'programmatic',
metric: 'overall',
score: 0.92,
kind: 'score',
});
});
it('should return feedback for all check categories', async () => {
mockProgrammaticEvaluation.mockResolvedValue(createMockEvaluationResult());
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test' });
const metrics = feedback.filter((f) => f.evaluator === 'programmatic').map((f) => f.metric);
expect(metrics).toContain('overall');
expect(metrics).toContain('connections');
expect(metrics).toContain('trigger');
expect(metrics).toContain('agentPrompt');
expect(metrics).toContain('tools');
expect(metrics).toContain('fromAi');
});
it('should include violations in feedback comments', async () => {
mockProgrammaticEvaluation.mockResolvedValue(
createMockEvaluationResult({
connections: {
score: 0.5,
violations: [
{ type: 'disconnected-node', description: 'Node A has no connections' },
{ type: 'invalid-connection', description: 'Invalid edge between B and C' },
],
},
}),
);
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test' });
const connectionsFeedback = findFeedback(feedback, 'connections');
expect(connectionsFeedback?.comment).toContain(
'[disconnected-node] Node A has no connections',
);
expect(connectionsFeedback?.comment).toContain(
'[invalid-connection] Invalid edge between B and C',
);
});
it('should not include comment when no violations', async () => {
mockProgrammaticEvaluation.mockResolvedValue(
createMockEvaluationResult({
trigger: { score: 1.0, violations: [] },
}),
);
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test' });
const triggerFeedback = findFeedback(feedback, 'trigger');
expect(triggerFeedback?.comment).toBeUndefined();
});
it('should include similarity feedback when reference workflows provided', async () => {
mockProgrammaticEvaluation.mockResolvedValue(
createMockEvaluationResult({
similarity: {
score: 0.75,
violations: [{ type: 'node-mismatch', description: 'Missing expected node' }],
},
}),
);
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
const feedback = await evaluator.evaluate(workflow, {
prompt: 'Test',
referenceWorkflows: [referenceWorkflow],
});
const similarityFeedback = findFeedback(feedback, 'similarity');
expect(similarityFeedback).toEqual({
evaluator: 'programmatic',
metric: 'similarity',
score: 0.75,
kind: 'metric',
comment: '[node-mismatch] Missing expected node',
});
});
it('should not include similarity feedback when result is null', async () => {
mockProgrammaticEvaluation.mockResolvedValue(
createMockEvaluationResult({
similarity: null,
}),
);
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test' });
const similarityFeedback = findFeedback(feedback, 'similarity');
expect(similarityFeedback).toBeUndefined();
});
it('should handle evaluation errors gracefully', async () => {
mockProgrammaticEvaluation.mockRejectedValue(new Error('Evaluation failed'));
const { createProgrammaticEvaluator } = await import('../../evaluators/programmatic');
const evaluator = createProgrammaticEvaluator(mockNodeTypes);
const workflow = createMockWorkflow();
// Should throw - let the runner handle errors
await expect(evaluator.evaluate(workflow, { prompt: 'Test' })).rejects.toThrow(
'Evaluation failed',
);
});
});
});
@@ -0,0 +1,306 @@
/**
* Tests for similarity evaluator factory.
*
* These tests mock the underlying workflow similarity functions and verify
* that the factory correctly wraps them and transforms the results.
*/
import type { SimpleWorkflow } from '@/types/workflow';
// Store mocks for similarity functions
const mockEvaluateWorkflowSimilarity = jest.fn();
const mockEvaluateWorkflowSimilarityMultiple = jest.fn();
// Mock the workflow similarity module
jest.mock('../../programmatic/evaluators/workflow-similarity', () => ({
evaluateWorkflowSimilarity: (...args: unknown[]): unknown =>
mockEvaluateWorkflowSimilarity(...args),
evaluateWorkflowSimilarityMultiple: (...args: unknown[]): unknown =>
mockEvaluateWorkflowSimilarityMultiple(...args),
}));
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
/** Helper to create mock similarity result */
function createMockSimilarityResult(
overrides: Partial<{
violations: Array<{ name: string; type: string; description: string; pointsDeducted: number }>;
score: number;
}> = {},
) {
return {
violations: [],
score: 0.85,
...overrides,
};
}
describe('Similarity Evaluator', () => {
beforeEach(() => {
jest.clearAllMocks();
});
type SimilarityFeedback = { evaluator: string; metric: string; score: number; comment?: string };
const findFeedback = (feedback: SimilarityFeedback[], metric: string) =>
feedback.find((f) => f.evaluator === 'similarity' && f.metric === metric);
describe('createSimilarityEvaluator()', () => {
it('should create an evaluator with correct name', async () => {
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
expect(evaluator.name).toBe('similarity');
});
it('should return error feedback when no reference workflow provided', async () => {
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
const workflow = createMockWorkflow();
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
expect(mockEvaluateWorkflowSimilarity).not.toHaveBeenCalled();
expect(mockEvaluateWorkflowSimilarityMultiple).not.toHaveBeenCalled();
expect(feedback).toContainEqual({
evaluator: 'similarity',
metric: 'error',
score: 0,
kind: 'score',
comment: 'No reference workflow provided for comparison',
});
});
it('should call evaluateWorkflowSimilarity with single reference workflow', async () => {
mockEvaluateWorkflowSimilarity.mockResolvedValue(createMockSimilarityResult());
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator({ preset: 'strict' });
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows: [referenceWorkflow],
});
expect(mockEvaluateWorkflowSimilarity).toHaveBeenCalledWith(
workflow,
referenceWorkflow,
'strict',
undefined,
);
});
it('should call evaluateWorkflowSimilarityMultiple with multiple reference workflows', async () => {
mockEvaluateWorkflowSimilarityMultiple.mockResolvedValue(createMockSimilarityResult());
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator({ preset: 'lenient' });
const workflow = createMockWorkflow();
const referenceWorkflows = [
createMockWorkflow('Reference 1'),
createMockWorkflow('Reference 2'),
];
await evaluator.evaluate(workflow, { prompt: 'Test prompt', referenceWorkflows });
expect(mockEvaluateWorkflowSimilarityMultiple).toHaveBeenCalledWith(
workflow,
referenceWorkflows,
'lenient',
undefined,
);
});
it('should use default preset when not specified', async () => {
mockEvaluateWorkflowSimilarity.mockResolvedValue(createMockSimilarityResult());
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows: [referenceWorkflow],
});
expect(mockEvaluateWorkflowSimilarity).toHaveBeenCalledWith(
workflow,
referenceWorkflow,
'standard',
undefined,
);
});
it('should return feedback with similarity score', async () => {
mockEvaluateWorkflowSimilarity.mockResolvedValue(createMockSimilarityResult({ score: 0.92 }));
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
const feedback = await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows: [referenceWorkflow],
});
const scoreFeedback = findFeedback(feedback, 'score');
expect(scoreFeedback?.score).toBe(0.92);
});
it('should include violations in score feedback comment', async () => {
mockEvaluateWorkflowSimilarity.mockResolvedValue(
createMockSimilarityResult({
score: 0.7,
violations: [
{
name: 'workflow-similarity-node-delete',
type: 'major',
description: 'Missing Slack node',
pointsDeducted: 10,
},
],
}),
);
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
const feedback = await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows: [referenceWorkflow],
});
const scoreFeedback = findFeedback(feedback, 'score');
expect(scoreFeedback?.comment).toContain('[major] Missing Slack node');
});
it('should return feedback for each violation type', async () => {
mockEvaluateWorkflowSimilarity.mockResolvedValue(
createMockSimilarityResult({
violations: [
{
name: 'workflow-similarity-node-delete',
type: 'major',
description: 'Missing node A',
pointsDeducted: 10,
},
{
name: 'workflow-similarity-node-delete',
type: 'major',
description: 'Missing node B',
pointsDeducted: 10,
},
{
name: 'workflow-similarity-edge-insert',
type: 'minor',
description: 'Extra connection',
pointsDeducted: 5,
},
],
}),
);
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
const feedback = await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows: [referenceWorkflow],
});
const nodeDeleteFeedback = findFeedback(feedback, 'node-delete');
expect(nodeDeleteFeedback).toBeDefined();
expect(nodeDeleteFeedback?.comment).toContain('2 node-delete');
const edgeInsertFeedback = findFeedback(feedback, 'edge-insert');
expect(edgeInsertFeedback).toBeDefined();
expect(edgeInsertFeedback?.comment).toContain('1 edge-insert');
});
it('should handle evaluation errors gracefully', async () => {
mockEvaluateWorkflowSimilarity.mockRejectedValue(new Error('uvx command not found'));
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
const feedback = await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows: [referenceWorkflow],
});
const errorFeedback = findFeedback(feedback, 'error');
expect(errorFeedback).toEqual({
evaluator: 'similarity',
metric: 'error',
score: 0,
kind: 'score',
comment: 'uvx command not found',
});
});
it('should pass custom config path when provided', async () => {
mockEvaluateWorkflowSimilarity.mockResolvedValue(createMockSimilarityResult());
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator({
preset: 'standard',
customConfigPath: '/path/to/config.json',
});
const workflow = createMockWorkflow();
const referenceWorkflow = createMockWorkflow('Reference');
await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows: [referenceWorkflow],
});
expect(mockEvaluateWorkflowSimilarity).toHaveBeenCalledWith(
workflow,
referenceWorkflow,
'standard',
'/path/to/config.json',
);
});
it('should use evaluateWorkflowSimilarityMultiple when referenceWorkflows has multiple items', async () => {
mockEvaluateWorkflowSimilarityMultiple.mockResolvedValue(createMockSimilarityResult());
const { createSimilarityEvaluator } = await import('../../evaluators/similarity');
const evaluator = createSimilarityEvaluator();
const workflow = createMockWorkflow();
const referenceWorkflows = [
createMockWorkflow('Reference 1'),
createMockWorkflow('Reference 2'),
];
await evaluator.evaluate(workflow, {
prompt: 'Test prompt',
referenceWorkflows,
});
expect(mockEvaluateWorkflowSimilarityMultiple).toHaveBeenCalled();
expect(mockEvaluateWorkflowSimilarity).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,152 @@
import { langsmithMetricKey, toLangsmithEvaluationResult } from '../harness/feedback';
import type { Feedback } from '../harness/harness-types';
describe('langsmithMetricKey()', () => {
it('should keep llm-judge metrics unprefixed (root and sub-metrics)', () => {
const root: Feedback = {
evaluator: 'llm-judge',
metric: 'overallScore',
score: 1,
kind: 'score',
};
const sub: Feedback = {
evaluator: 'llm-judge',
metric: 'maintainability.workflowOrganization',
score: 1,
kind: 'detail',
};
expect(langsmithMetricKey(root)).toBe('overallScore');
expect(langsmithMetricKey(sub)).toBe('maintainability.workflowOrganization');
});
it('should prefix programmatic metrics with evaluator name', () => {
const fb: Feedback = { evaluator: 'programmatic', metric: 'trigger', score: 1, kind: 'metric' };
expect(langsmithMetricKey(fb)).toBe('programmatic.trigger');
});
it('should keep pairwise v1 metrics unprefixed and namespace non-v1 details', () => {
const v1: Feedback = {
evaluator: 'pairwise',
metric: 'pairwise_primary',
score: 0,
kind: 'score',
};
const detail: Feedback = { evaluator: 'pairwise', metric: 'judge1', score: 0, kind: 'detail' };
expect(langsmithMetricKey(v1)).toBe('pairwise_primary');
expect(langsmithMetricKey(detail)).toBe('pairwise.judge1');
});
it('should prefix metrics evaluator with evaluator name', () => {
const discoveryLatency: Feedback = {
evaluator: 'metrics',
metric: 'discovery_latency_s',
score: 0.5,
kind: 'metric',
};
const builderLatency: Feedback = {
evaluator: 'metrics',
metric: 'builder_latency_s',
score: 1.5,
kind: 'metric',
};
const responderLatency: Feedback = {
evaluator: 'metrics',
metric: 'responder_latency_s',
score: 0.2,
kind: 'metric',
};
const nodeCount: Feedback = {
evaluator: 'metrics',
metric: 'node_count',
score: 5,
kind: 'metric',
};
expect(langsmithMetricKey(discoveryLatency)).toBe('metrics.discovery_latency_s');
expect(langsmithMetricKey(builderLatency)).toBe('metrics.builder_latency_s');
expect(langsmithMetricKey(responderLatency)).toBe('metrics.responder_latency_s');
expect(langsmithMetricKey(nodeCount)).toBe('metrics.node_count');
});
it('should not produce collisions for the known evaluator contract', () => {
const feedback: Feedback[] = [
{ evaluator: 'llm-judge', metric: 'connections', score: 1, kind: 'metric' },
{ evaluator: 'llm-judge', metric: 'overallScore', score: 1, kind: 'score' },
{ evaluator: 'programmatic', metric: 'connections', score: 1, kind: 'metric' },
{ evaluator: 'programmatic', metric: 'overall', score: 1, kind: 'score' },
{ evaluator: 'pairwise', metric: 'pairwise_primary', score: 1, kind: 'score' },
{ evaluator: 'pairwise', metric: 'pairwise_total_violations', score: 1, kind: 'detail' },
{ evaluator: 'pairwise', metric: 'judge1', score: 0, kind: 'detail' },
{ evaluator: 'metrics', metric: 'discovery_latency_s', score: 0.5, kind: 'metric' },
{ evaluator: 'metrics', metric: 'node_count', score: 5, kind: 'metric' },
];
const keys = feedback.map(langsmithMetricKey);
expect(new Set(keys).size).toBe(keys.length);
});
});
describe('toLangsmithEvaluationResult()', () => {
it('should clamp scores exceeding LangSmith max limit (safety net)', () => {
const fb: Feedback = {
evaluator: 'metrics',
metric: 'some_metric',
score: 150000, // Exceeds 99999.9999
kind: 'metric',
};
const result = toLangsmithEvaluationResult(fb);
expect(result.score).toBe(99999.9999);
});
it('should clamp scores below LangSmith min limit (safety net)', () => {
const fb: Feedback = {
evaluator: 'metrics',
metric: 'some_metric',
score: -200000,
kind: 'metric',
};
const result = toLangsmithEvaluationResult(fb);
expect(result.score).toBe(-99999.9999);
});
it('should preserve scores within valid range', () => {
const fb: Feedback = {
evaluator: 'metrics',
metric: 'discovery_latency_s',
score: 161.288, // 161 seconds, well within limits
kind: 'metric',
};
const result = toLangsmithEvaluationResult(fb);
expect(result.score).toBe(161.288);
});
it('should include comment when present', () => {
const fb: Feedback = {
evaluator: 'llm-judge',
metric: 'overallScore',
score: 0.85,
kind: 'score',
comment: 'Good workflow',
};
const result = toLangsmithEvaluationResult(fb);
expect(result.comment).toBe('Good workflow');
});
it('should not include comment when absent', () => {
const fb: Feedback = {
evaluator: 'llm-judge',
metric: 'overallScore',
score: 0.85,
kind: 'score',
};
const result = toLangsmithEvaluationResult(fb);
expect(result.comment).toBeUndefined();
});
});
@@ -0,0 +1,852 @@
/**
* Tests for default console lifecycle implementation.
*/
import { mock } from 'jest-mock-extended';
import type { Client } from 'langsmith/client';
import type { SimpleWorkflow } from '@/types/workflow';
import type {
EvaluationLifecycle,
RunConfig,
ExampleResult,
RunSummary,
Feedback,
} from '../harness/harness-types';
import { createLogger } from '../harness/logger';
const mockLangsmithClient = () => mock<Client>();
// Mock console methods
const mockConsole = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
// Store original console
const originalConsole = { ...console };
beforeEach(() => {
jest.clearAllMocks();
console.log = mockConsole.log;
console.warn = mockConsole.warn;
console.error = mockConsole.error;
});
afterEach(() => {
console.log = originalConsole.log;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
});
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
describe('Console Lifecycle', () => {
describe('createConsoleLifecycle()', () => {
it('should create a lifecycle with all hooks', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
expect(lifecycle.onStart).toBeDefined();
expect(lifecycle.onExampleStart).toBeDefined();
expect(lifecycle.onWorkflowGenerated).toBeDefined();
expect(lifecycle.onEvaluatorComplete).toBeDefined();
expect(lifecycle.onEvaluatorError).toBeDefined();
expect(lifecycle.onExampleComplete).toBeDefined();
expect(lifecycle.onEnd).toBeDefined();
});
it('should log experiment info on start with test cases array', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [],
logger: createLogger(false),
};
lifecycle.onStart(config);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('local');
expect(logOutput).toContain('Test cases');
});
it('should log dataset name for langsmith mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
const config: RunConfig = {
mode: 'langsmith',
dataset: 'my-dataset-name',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [{ name: 'test-eval', evaluate: jest.fn() }],
langsmithClient: mockLangsmithClient(),
langsmithOptions: {
experimentName: 'test-experiment',
repetitions: 1,
concurrency: 1,
},
logger: createLogger(false),
};
lifecycle.onStart(config);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('langsmith');
expect(logOutput).toContain('my-dataset-name');
});
it('should not log summary in langsmith mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
const config: RunConfig = {
mode: 'langsmith',
dataset: 'my-dataset-name',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [{ name: 'test-eval', evaluate: jest.fn() }],
langsmithClient: mockLangsmithClient(),
langsmithOptions: {
experimentName: 'test-experiment',
repetitions: 1,
concurrency: 1,
},
logger: createLogger(false),
};
lifecycle.onStart(config);
mockConsole.log.mockClear();
await lifecycle.onEnd({
totalExamples: 0,
passed: 0,
failed: 0,
errors: 0,
averageScore: 0,
totalDurationMs: 0,
});
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should log example progress in verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
lifecycle.onExampleStart(1, 10, 'Test prompt that is quite long and should be truncated');
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('[ex 1/10]');
});
it('should not log example progress in non-verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
lifecycle.onExampleStart(1, 10, 'Test prompt');
// Should not log in non-verbose mode
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should log workflow generation in verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const workflow = createMockWorkflow('My Workflow');
lifecycle.onWorkflowGenerated(workflow, 1500);
// workflow generation is reported as part of the example completion block
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should log evaluator completion in verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
lifecycle.onEvaluatorComplete('llm-judge', [
{ evaluator: 'llm-judge', metric: 'func', score: 0.8, kind: 'metric' },
{ evaluator: 'llm-judge', metric: 'conn', score: 0.9, kind: 'metric' },
]);
// evaluator completion is reported as part of the example completion block
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should not log evaluator completion in non-verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
lifecycle.onEvaluatorComplete('llm-judge', [
{ evaluator: 'llm-judge', metric: 'func', score: 0.8, kind: 'metric' },
]);
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should display critical metrics in verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const result: ExampleResult = {
index: 1,
prompt: 'Test prompt that is quite long and should be truncated for display in logs',
status: 'pass',
score: 0.85,
feedback: [
{ evaluator: 'llm-judge', metric: 'functionality', score: 0.95, kind: 'metric' },
{ evaluator: 'llm-judge', metric: 'connections', score: 0.8, kind: 'metric' },
{ evaluator: 'llm-judge', metric: 'overallScore', score: 0.85, kind: 'score' },
{ evaluator: 'other', metric: 'metric', score: 0.5, kind: 'detail' },
],
durationMs: 2000,
generationDurationMs: 1500,
evaluationDurationMs: 500,
workflow: createMockWorkflow(),
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('prompt=');
expect(logOutput).toContain('functionality');
expect(logOutput).toContain('connections');
expect(logOutput).toContain('overallScore');
});
it('should display violations in verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'fail',
score: 0.3,
feedback: [
{
evaluator: 'llm-judge',
metric: 'functionality',
score: 0.5,
comment: '[critical] Missing trigger node',
kind: 'metric',
},
{
evaluator: 'llm-judge',
metric: 'connections',
score: 0.3,
comment: '[major] Disconnected node found',
kind: 'metric',
},
],
durationMs: 1500,
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('issues');
expect(logOutput).toContain('Missing trigger node');
expect(logOutput).toContain('Disconnected node found');
});
it('should display pairwise judge violations in verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'fail',
score: 0.5,
feedback: [
{
evaluator: 'pairwise',
metric: 'pairwise_primary',
score: 0,
comment: '1/3 judges passed',
kind: 'score',
},
{
evaluator: 'pairwise',
metric: 'judge2',
score: 0,
comment: '[No HTTP] Contains HTTP Request node',
kind: 'detail',
},
],
durationMs: 1500,
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('issues');
expect(logOutput).toContain('judge2');
expect(logOutput).toContain('Contains HTTP Request node');
});
it('should limit violations display to 5 and show count', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'fail',
score: 0.5,
feedback: [
{
evaluator: 'llm-judge',
metric: 'v1',
score: 0.5,
comment: '[minor] Violation 1',
kind: 'detail',
},
{
evaluator: 'llm-judge',
metric: 'v2',
score: 0.5,
comment: '[minor] Violation 2',
kind: 'detail',
},
{
evaluator: 'llm-judge',
metric: 'v3',
score: 0.5,
comment: '[minor] Violation 3',
kind: 'detail',
},
{
evaluator: 'llm-judge',
metric: 'v4',
score: 0.5,
comment: '[minor] Violation 4',
kind: 'detail',
},
],
durationMs: 1000,
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('and 1 more');
});
it('should not display violations for error feedback', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'error',
score: 0,
feedback: [
{
evaluator: 'llm-judge',
metric: 'error',
score: 0,
comment: 'Crashed',
kind: 'score',
},
],
durationMs: 500,
error: 'Generation failed',
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).not.toContain('issues');
});
it('should handle empty feedback array', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'fail',
score: 0,
feedback: [],
durationMs: 500,
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('0%');
});
it('should log evaluator errors in verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
lifecycle.onEvaluatorError('test-evaluator', new Error('Something went wrong'));
expect(mockConsole.error).toHaveBeenCalled();
const errorOutput = mockConsole.error.mock.calls.flat().join(' ');
expect(errorOutput).toContain('test-evaluator');
expect(errorOutput).toContain('Something went wrong');
});
it('should NOT log evaluator errors in non-verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
lifecycle.onEvaluatorError('test-evaluator', new Error('Something went wrong'));
expect(mockConsole.error).not.toHaveBeenCalled();
});
it('should log example completion with pass/fail status', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const passResult: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'pass',
score: 0.9,
feedback: [{ evaluator: 'test-eval', metric: 'test', score: 0.9, kind: 'score' }],
durationMs: 2000,
};
const failResult: ExampleResult = {
index: 2,
prompt: 'Test',
status: 'fail',
score: 0.3,
feedback: [{ evaluator: 'test-eval', metric: 'test', score: 0.3, kind: 'score' }],
durationMs: 1500,
};
lifecycle.onExampleComplete(1, passResult);
lifecycle.onExampleComplete(2, failResult);
const allOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(allOutput).toContain('PASS');
expect(allOutput).toContain('FAIL');
});
it('should log example completion with error status', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const errorResult: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'error',
score: 0,
feedback: [],
durationMs: 500,
error: 'Generation failed',
};
lifecycle.onExampleComplete(1, errorResult);
const allOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(allOutput).toContain('ERROR');
});
it('should not log example completion in non-verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'pass',
score: 0.9,
feedback: [{ evaluator: 'test-eval', metric: 'test', score: 0.9, kind: 'score' }],
durationMs: 2000,
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should not log workflow generation in non-verbose mode', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
const workflow = createMockWorkflow('My Workflow');
lifecycle.onWorkflowGenerated(workflow, 1500);
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should use different colors for different score ranges', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(true) });
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'pass',
score: 0.8,
feedback: [
{ evaluator: 'high', metric: 'test', score: 0.95, kind: 'score' },
{ evaluator: 'medium', metric: 'test', score: 0.75, kind: 'score' },
{ evaluator: 'low', metric: 'test', score: 0.5, kind: 'score' },
],
durationMs: 1000,
};
lifecycle.onExampleComplete(1, result);
expect(mockConsole.log).toHaveBeenCalled();
// The coloring is applied, tests verify that the function runs without error
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('high');
expect(logOutput).toContain('medium');
expect(logOutput).toContain('low');
});
it('should log summary with statistics on end', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: false, logger: createLogger(false) });
const summary: RunSummary = {
totalExamples: 10,
passed: 7,
failed: 2,
errors: 1,
averageScore: 0.85,
totalDurationMs: 30000,
};
await lifecycle.onEnd(summary);
expect(mockConsole.log).toHaveBeenCalled();
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).toContain('10');
expect(logOutput).toContain('7');
expect(logOutput).toContain('85%');
});
it('should not print NaN when feedback contains non-finite scores', async () => {
const { createConsoleLifecycle } = await import('../harness/lifecycle');
const lifecycle = createConsoleLifecycle({ verbose: true, logger: createLogger(false) });
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [{ name: 'programmatic', evaluate: jest.fn() }],
logger: createLogger(false),
};
lifecycle.onStart(config);
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'pass',
score: 1,
durationMs: 10,
workflow: createMockWorkflow(),
feedback: [
{ evaluator: 'programmatic', metric: 'connections', score: 1, kind: 'metric' },
{ evaluator: 'programmatic', metric: 'trigger', score: Number.NaN, kind: 'metric' },
],
};
lifecycle.onExampleComplete(1, result);
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
expect(logOutput).not.toContain('NaN');
});
});
describe('createQuietLifecycle()', () => {
it('should create lifecycle with empty hooks', async () => {
const { createQuietLifecycle } = await import('../harness/lifecycle');
const lifecycle = createQuietLifecycle();
// Should have all hooks
expect(lifecycle.onStart).toBeDefined();
expect(lifecycle.onEnd).toBeDefined();
// Hooks should be no-ops
const config: RunConfig = {
mode: 'local',
dataset: [],
generateWorkflow: jest.fn(),
evaluators: [],
logger: createLogger(false),
};
lifecycle.onStart(config);
lifecycle.onExampleStart(1, 1, 'Test');
await lifecycle.onEnd({
totalExamples: 1,
passed: 1,
failed: 0,
errors: 0,
averageScore: 1,
totalDurationMs: 1000,
});
// Should not log anything
expect(mockConsole.log).not.toHaveBeenCalled();
});
});
describe('mergeLifecycles()', () => {
it('should merge multiple lifecycles into one', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook1 = jest.fn();
const hook2 = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = {
onStart: hook1,
};
const lifecycle2: Partial<EvaluationLifecycle> = {
onStart: hook2,
};
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const config: RunConfig = {
mode: 'local',
dataset: [],
generateWorkflow: jest.fn(),
evaluators: [],
logger: createLogger(false),
};
merged.onStart(config);
expect(hook1).toHaveBeenCalledWith(config);
expect(hook2).toHaveBeenCalledWith(config);
});
it('should handle undefined hooks gracefully', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = {
onStart: hook,
};
const lifecycle2: Partial<EvaluationLifecycle> = {
// No onStart
};
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const config: RunConfig = {
mode: 'local',
dataset: [],
generateWorkflow: jest.fn(),
evaluators: [],
logger: createLogger(false),
};
merged.onStart(config);
expect(hook).toHaveBeenCalledWith(config);
});
it('should handle undefined lifecycles in array', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = {
onStart: hook,
};
const merged = mergeLifecycles(lifecycle1, undefined, undefined);
const config: RunConfig = {
mode: 'local',
dataset: [],
generateWorkflow: jest.fn(),
evaluators: [],
logger: createLogger(false),
};
merged.onStart(config);
expect(hook).toHaveBeenCalledWith(config);
});
it('should merge onExampleStart hooks', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook1 = jest.fn();
const hook2 = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = { onExampleStart: hook1 };
const lifecycle2: Partial<EvaluationLifecycle> = { onExampleStart: hook2 };
const merged = mergeLifecycles(lifecycle1, lifecycle2);
merged.onExampleStart(1, 10, 'Test prompt');
expect(hook1).toHaveBeenCalledWith(1, 10, 'Test prompt');
expect(hook2).toHaveBeenCalledWith(1, 10, 'Test prompt');
});
it('should merge onWorkflowGenerated hooks', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook1 = jest.fn();
const hook2 = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = { onWorkflowGenerated: hook1 };
const lifecycle2: Partial<EvaluationLifecycle> = { onWorkflowGenerated: hook2 };
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const workflow = createMockWorkflow();
merged.onWorkflowGenerated(workflow, 1000);
expect(hook1).toHaveBeenCalledWith(workflow, 1000);
expect(hook2).toHaveBeenCalledWith(workflow, 1000);
});
it('should merge onEvaluatorComplete hooks', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook1 = jest.fn();
const hook2 = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = { onEvaluatorComplete: hook1 };
const lifecycle2: Partial<EvaluationLifecycle> = { onEvaluatorComplete: hook2 };
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const feedback: Feedback[] = [
{ evaluator: 'test-eval', metric: 'test', score: 0.9, kind: 'score' },
];
merged.onEvaluatorComplete('test-eval', feedback);
expect(hook1).toHaveBeenCalledWith('test-eval', feedback);
expect(hook2).toHaveBeenCalledWith('test-eval', feedback);
});
it('should merge onEvaluatorError hooks', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook1 = jest.fn();
const hook2 = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = { onEvaluatorError: hook1 };
const lifecycle2: Partial<EvaluationLifecycle> = { onEvaluatorError: hook2 };
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const error = new Error('Test error');
merged.onEvaluatorError('test-eval', error);
expect(hook1).toHaveBeenCalledWith('test-eval', error);
expect(hook2).toHaveBeenCalledWith('test-eval', error);
});
it('should merge onExampleComplete hooks', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook1 = jest.fn();
const hook2 = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = { onExampleComplete: hook1 };
const lifecycle2: Partial<EvaluationLifecycle> = { onExampleComplete: hook2 };
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const result: ExampleResult = {
index: 1,
prompt: 'Test',
status: 'pass',
score: 1,
feedback: [],
durationMs: 1000,
};
merged.onExampleComplete(1, result);
expect(hook1).toHaveBeenCalledWith(1, result);
expect(hook2).toHaveBeenCalledWith(1, result);
});
it('should merge onEnd hooks', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const hook1 = jest.fn();
const hook2 = jest.fn();
const lifecycle1: Partial<EvaluationLifecycle> = { onEnd: hook1 };
const lifecycle2: Partial<EvaluationLifecycle> = { onEnd: hook2 };
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const summary: RunSummary = {
totalExamples: 10,
passed: 8,
failed: 1,
errors: 1,
averageScore: 0.85,
totalDurationMs: 5000,
};
await merged.onEnd(summary);
expect(hook1).toHaveBeenCalledWith(summary);
expect(hook2).toHaveBeenCalledWith(summary);
});
it('should properly await async onEnd hooks in mergeLifecycles', async () => {
const { mergeLifecycles } = await import('../harness/lifecycle');
const callOrder: string[] = [];
const asyncHook = jest.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
callOrder.push('async');
});
const syncHook = jest.fn(() => {
callOrder.push('sync');
});
const lifecycle1: Partial<EvaluationLifecycle> = { onEnd: asyncHook };
const lifecycle2: Partial<EvaluationLifecycle> = { onEnd: syncHook };
const merged = mergeLifecycles(lifecycle1, lifecycle2);
const summary: RunSummary = {
totalExamples: 1,
passed: 1,
failed: 0,
errors: 0,
averageScore: 1,
totalDurationMs: 100,
};
await merged.onEnd(summary);
expect(asyncHook).toHaveBeenCalledWith(summary);
expect(syncHook).toHaveBeenCalledWith(summary);
// Async hook should complete before sync hook starts (sequential await)
expect(callOrder).toEqual(['async', 'sync']);
});
});
});
@@ -0,0 +1,403 @@
/**
* Tests for artifact saving functionality.
*/
import * as fs from 'fs';
import { jsonParse } from 'n8n-workflow';
import * as os from 'os';
import * as path from 'path';
import type { SimpleWorkflow } from '@/types/workflow';
import type { ExampleResult, RunSummary } from '../harness/harness-types';
import { createLogger } from '../harness/logger';
import { createArtifactSaver } from '../harness/output';
const silentLogger = createLogger(false);
function findExampleDir(baseDir: string, paddedIndex: string): string {
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
const prefix = `example-${paddedIndex}-`;
const match = entries.find((e) => e.isDirectory() && e.name.startsWith(prefix));
if (!match) throw new Error(`Expected example dir starting with "${prefix}" in ${baseDir}`);
return path.join(baseDir, match.name);
}
/** Type for parsed workflow JSON */
interface ParsedWorkflow {
name: string;
nodes: unknown[];
connections: Record<string, unknown>;
}
/** Type for parsed feedback JSON */
interface ParsedFeedback {
index: number;
status: string;
score: number;
evaluators: Array<{
name: string;
averageScore: number;
feedback: Array<{ key: string; score: number }>;
}>;
subgraphMetrics?: {
nodeCount?: number;
discoveryDurationMs?: number;
builderDurationMs?: number;
responderDurationMs?: number;
};
}
/** Type for parsed summary JSON */
interface ParsedSummary {
totalExamples: number;
passed: number;
failed: number;
passRate: number;
timestamp: string;
evaluatorAverages: Record<string, number>;
results: Array<{
prompt: string;
nodeCount?: number;
discoveryDurationMs?: number;
builderDurationMs?: number;
responderDurationMs?: number;
}>;
}
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return {
name,
nodes: [
{
id: '1',
type: 'n8n-nodes-base.start',
name: 'Start',
position: [0, 0],
typeVersion: 1,
parameters: {},
},
],
connections: {},
};
}
/** Helper to create a mock example result */
function createMockResult(overrides: Partial<ExampleResult> = {}): ExampleResult {
return {
index: 1,
prompt: 'Create a test workflow',
status: 'pass',
score: 0.9,
feedback: [
{ evaluator: 'llm-judge', metric: 'functionality', score: 0.9, kind: 'metric' },
{ evaluator: 'llm-judge', metric: 'connections', score: 0.8, kind: 'metric' },
{ evaluator: 'programmatic', metric: 'overall', score: 1.0, kind: 'score' },
],
durationMs: 1500,
workflow: createMockWorkflow(),
...overrides,
};
}
/** Helper to create a mock summary */
function createMockSummary(): RunSummary {
return {
totalExamples: 3,
passed: 2,
failed: 1,
errors: 0,
averageScore: 0.85,
totalDurationMs: 5000,
};
}
describe('Artifact Saver', () => {
let tempDir: string;
beforeEach(() => {
// Create a unique temp directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'v2-eval-test-'));
});
afterEach(() => {
// Clean up temp directory
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('createArtifactSaver()', () => {
it('should create output directory if it does not exist', () => {
const outputDir = path.join(tempDir, 'nested', 'output');
createArtifactSaver({ outputDir, logger: silentLogger });
expect(fs.existsSync(outputDir)).toBe(true);
});
it('should return an artifact saver with saveExample and saveSummary methods', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
expect(saver.saveExample).toBeDefined();
expect(saver.saveSummary).toBeDefined();
});
});
describe('saveExample()', () => {
it('should save prompt to prompt.txt', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult({ index: 1 });
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const promptPath = path.join(exampleDir, 'prompt.txt');
expect(fs.existsSync(promptPath)).toBe(true);
expect(fs.readFileSync(promptPath, 'utf-8')).toBe('Create a test workflow');
});
it('should save workflow to workflow.json in n8n-importable format', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult();
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const workflowPath = path.join(exampleDir, 'workflow.json');
expect(fs.existsSync(workflowPath)).toBe(true);
const workflow = jsonParse<ParsedWorkflow>(fs.readFileSync(workflowPath, 'utf-8'));
expect(workflow.name).toBe('Test Workflow');
expect(workflow.nodes).toHaveLength(1);
expect(workflow.connections).toEqual({});
});
it('should save feedback to feedback.json', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult();
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const feedbackPath = path.join(exampleDir, 'feedback.json');
expect(fs.existsSync(feedbackPath)).toBe(true);
const feedback = jsonParse<ParsedFeedback>(fs.readFileSync(feedbackPath, 'utf-8'));
expect(feedback.index).toBe(1);
expect(feedback.status).toBe('pass');
expect(feedback.evaluators).toHaveLength(2); // llm-judge and programmatic
});
it('should group feedback by evaluator', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult();
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const feedbackPath = path.join(exampleDir, 'feedback.json');
const feedback = jsonParse<ParsedFeedback>(fs.readFileSync(feedbackPath, 'utf-8'));
const llmJudge = feedback.evaluators.find((e) => e.name === 'llm-judge');
expect(llmJudge?.feedback).toHaveLength(2);
const programmatic = feedback.evaluators.find((e) => e.name === 'programmatic');
expect(programmatic?.feedback).toHaveLength(1);
});
it('should ignore non-finite scores when computing evaluator averages', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult({
feedback: [
{ evaluator: 'programmatic', metric: 'connections', score: 1, kind: 'metric' },
{ evaluator: 'programmatic', metric: 'trigger', score: Number.NaN, kind: 'metric' },
],
});
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const feedbackPath = path.join(exampleDir, 'feedback.json');
const feedback = jsonParse<ParsedFeedback>(fs.readFileSync(feedbackPath, 'utf-8'));
const programmatic = feedback.evaluators.find((e) => e.name === 'programmatic');
expect(programmatic).toBeDefined();
expect(programmatic?.averageScore).toBe(1);
});
it('should save error to error.txt when present', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult({
status: 'error',
score: 0,
error: 'Generation failed: timeout',
});
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const errorPath = path.join(exampleDir, 'error.txt');
expect(fs.existsSync(errorPath)).toBe(true);
expect(fs.readFileSync(errorPath, 'utf-8')).toBe('Generation failed: timeout');
});
it('should not save workflow.json when workflow is undefined', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult({ workflow: undefined });
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const workflowPath = path.join(exampleDir, 'workflow.json');
expect(fs.existsSync(workflowPath)).toBe(false);
});
it('should include subgraph metrics in feedback.json when present', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult({
subgraphMetrics: {
nodeCount: 7,
discoveryDurationMs: 350,
builderDurationMs: 900,
responderDurationMs: 200,
},
});
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const feedbackPath = path.join(exampleDir, 'feedback.json');
const feedback = jsonParse<ParsedFeedback>(fs.readFileSync(feedbackPath, 'utf-8'));
expect(feedback.subgraphMetrics).toBeDefined();
expect(feedback.subgraphMetrics?.nodeCount).toBe(7);
expect(feedback.subgraphMetrics?.discoveryDurationMs).toBe(350);
expect(feedback.subgraphMetrics?.builderDurationMs).toBe(900);
expect(feedback.subgraphMetrics?.responderDurationMs).toBe(200);
});
it('should not include subgraph metrics in feedback.json when not present', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const result = createMockResult(); // No subgraphMetrics
saver.saveExample(result);
const exampleDir = findExampleDir(tempDir, '001');
const feedbackPath = path.join(exampleDir, 'feedback.json');
const feedback = jsonParse<ParsedFeedback>(fs.readFileSync(feedbackPath, 'utf-8'));
expect(feedback.subgraphMetrics).toBeUndefined();
});
it('should pad example index in directory name', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
saver.saveExample(createMockResult({ index: 1 }));
saver.saveExample(createMockResult({ index: 10 }));
saver.saveExample(createMockResult({ index: 100 }));
expect(() => findExampleDir(tempDir, '001')).not.toThrow();
expect(() => findExampleDir(tempDir, '010')).not.toThrow();
expect(() => findExampleDir(tempDir, '100')).not.toThrow();
});
});
describe('saveSummary()', () => {
it('should save summary.json with correct structure', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const summary = createMockSummary();
const results = [
createMockResult({ index: 1, status: 'pass' }),
createMockResult({ index: 2, status: 'pass' }),
createMockResult({ index: 3, status: 'fail' }),
];
saver.saveSummary(summary, results);
const summaryPath = path.join(tempDir, 'summary.json');
expect(fs.existsSync(summaryPath)).toBe(true);
const savedSummary = jsonParse<ParsedSummary>(fs.readFileSync(summaryPath, 'utf-8'));
expect(savedSummary.totalExamples).toBe(3);
expect(savedSummary.passed).toBe(2);
expect(savedSummary.failed).toBe(1);
expect(savedSummary.passRate).toBeCloseTo(2 / 3);
});
it('should include timestamp', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
saver.saveSummary(createMockSummary(), [createMockResult()]);
const summaryPath = path.join(tempDir, 'summary.json');
const savedSummary = jsonParse<ParsedSummary>(fs.readFileSync(summaryPath, 'utf-8'));
expect(savedSummary.timestamp).toBeDefined();
expect(new Date(savedSummary.timestamp).getTime()).not.toBeNaN();
});
it('should calculate per-evaluator averages', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const results = [createMockResult(), createMockResult()];
saver.saveSummary(createMockSummary(), results);
const summaryPath = path.join(tempDir, 'summary.json');
const savedSummary = jsonParse<ParsedSummary>(fs.readFileSync(summaryPath, 'utf-8'));
expect(savedSummary.evaluatorAverages).toBeDefined();
expect(savedSummary.evaluatorAverages['llm-judge']).toBeCloseTo(0.85);
expect(savedSummary.evaluatorAverages['programmatic']).toBe(1.0);
});
it('should include truncated prompts in results', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const longPrompt = 'A'.repeat(200);
const results = [createMockResult({ prompt: longPrompt })];
saver.saveSummary(createMockSummary(), results);
const summaryPath = path.join(tempDir, 'summary.json');
const savedSummary = jsonParse<ParsedSummary>(fs.readFileSync(summaryPath, 'utf-8'));
expect(savedSummary.results[0].prompt.length).toBeLessThan(longPrompt.length);
expect(savedSummary.results[0].prompt).toContain('...');
});
it('should include subgraph metrics in summary results when present', () => {
const saver = createArtifactSaver({ outputDir: tempDir, logger: silentLogger });
const results = [
createMockResult({
index: 1,
subgraphMetrics: {
nodeCount: 5,
discoveryDurationMs: 250,
builderDurationMs: 750,
responderDurationMs: 150,
},
}),
createMockResult({
index: 2,
// No subgraphMetrics
}),
];
saver.saveSummary(createMockSummary(), results);
const summaryPath = path.join(tempDir, 'summary.json');
const savedSummary = jsonParse<ParsedSummary>(fs.readFileSync(summaryPath, 'utf-8'));
// First result should have subgraph metrics
expect(savedSummary.results[0].nodeCount).toBe(5);
expect(savedSummary.results[0].discoveryDurationMs).toBe(250);
expect(savedSummary.results[0].builderDurationMs).toBe(750);
expect(savedSummary.results[0].responderDurationMs).toBe(150);
// Second result should not have subgraph metrics
expect(savedSummary.results[1].nodeCount).toBeUndefined();
expect(savedSummary.results[1].discoveryDurationMs).toBeUndefined();
expect(savedSummary.results[1].builderDurationMs).toBeUndefined();
expect(savedSummary.results[1].responderDurationMs).toBeUndefined();
});
});
});
@@ -0,0 +1,308 @@
/**
* Tests for markdown report generation.
*
* These utilities generate human-readable markdown reports
* from evaluation results.
*/
import type { ExampleResult, RunSummary, Feedback } from '../harness/harness-types';
import {
extractViolationSeverity,
calculateReportMetrics,
generateMarkdownReport,
} from '../support/report-generator';
/** Helper to create a feedback item */
function createFeedback(
evaluator: string,
metric: string,
score: number,
kind: Feedback['kind'] = 'metric',
comment?: string,
): Feedback {
return { evaluator, metric, score, kind, ...(comment ? { comment } : {}) };
}
/** Helper to create an example result */
function createExampleResult(overrides: Partial<ExampleResult> = {}): ExampleResult {
return {
index: 1,
prompt: 'Test prompt',
status: 'pass',
score: 0,
feedback: [],
durationMs: 1000,
...overrides,
};
}
/** Helper to create a run summary */
function createRunSummary(overrides: Partial<RunSummary> = {}): RunSummary {
return {
totalExamples: 10,
passed: 8,
failed: 1,
errors: 1,
averageScore: 0.75,
totalDurationMs: 10000,
...overrides,
};
}
describe('Report Generator', () => {
describe('extractViolationSeverity()', () => {
it('should extract critical severity', () => {
expect(extractViolationSeverity('[CRITICAL] Missing trigger')).toBe('critical');
});
it('should extract major severity', () => {
expect(extractViolationSeverity('[MAJOR] Bad configuration')).toBe('major');
});
it('should extract minor severity', () => {
expect(extractViolationSeverity('[MINOR] Style issue')).toBe('minor');
});
it('should return null for no violation marker', () => {
expect(extractViolationSeverity('Just a comment')).toBeNull();
});
it('should return null for undefined comment', () => {
expect(extractViolationSeverity(undefined)).toBeNull();
});
it('should be case-insensitive', () => {
expect(extractViolationSeverity('[critical] lowercase')).toBe('critical');
expect(extractViolationSeverity('[Critical] mixed')).toBe('critical');
});
});
describe('calculateReportMetrics()', () => {
it('should calculate evaluator averages from feedback keys', () => {
const results: ExampleResult[] = [
createExampleResult({
feedback: [
createFeedback('llm-judge', 'functionality', 0.8),
createFeedback('llm-judge', 'connections', 0.6),
createFeedback('programmatic', 'trigger', 1.0),
],
}),
];
const metrics = calculateReportMetrics(results);
expect(metrics.evaluatorAverages['llm-judge']).toBeCloseTo(0.7);
expect(metrics.evaluatorAverages['programmatic']).toBeCloseTo(1.0);
});
it('should ignore non-finite scores when computing evaluator averages', () => {
const results: ExampleResult[] = [
createExampleResult({
feedback: [
createFeedback('programmatic', 'connections', 1),
createFeedback('programmatic', 'trigger', Number.NaN),
],
}),
];
const metrics = calculateReportMetrics(results);
expect(metrics.evaluatorAverages['programmatic']).toBe(1);
});
it('should count violations by severity from comments', () => {
const results: ExampleResult[] = [
createExampleResult({
feedback: [
createFeedback('a', 'b', 0, 'detail', '[CRITICAL] Missing node'),
createFeedback('a', 'c', 0, 'detail', '[MAJOR] Bad config'),
createFeedback('a', 'd', 0, 'detail', '[MINOR] Style issue'),
createFeedback('a', 'e', 0, 'detail', '[CRITICAL] Another critical'),
],
}),
];
const metrics = calculateReportMetrics(results);
expect(metrics.violationCounts.critical).toBe(2);
expect(metrics.violationCounts.major).toBe(1);
expect(metrics.violationCounts.minor).toBe(1);
});
it('should handle empty results', () => {
const metrics = calculateReportMetrics([]);
expect(metrics.evaluatorAverages).toEqual({});
expect(metrics.violationCounts).toEqual({ critical: 0, major: 0, minor: 0 });
});
it('should aggregate across multiple results', () => {
const results: ExampleResult[] = [
createExampleResult({
feedback: [createFeedback('llm-judge', 'a', 0.8)],
}),
createExampleResult({
feedback: [createFeedback('llm-judge', 'a', 0.6)],
}),
];
const metrics = calculateReportMetrics(results);
expect(metrics.evaluatorAverages['llm-judge']).toBeCloseTo(0.7);
});
it('should handle results with errors', () => {
const results: ExampleResult[] = [
createExampleResult({
status: 'error',
feedback: [],
error: 'Something went wrong',
}),
createExampleResult({
feedback: [createFeedback('llm-judge', 'a', 0.8)],
}),
];
const metrics = calculateReportMetrics(results);
// Should still calculate from successful results
expect(metrics.evaluatorAverages['llm-judge']).toBeCloseTo(0.8);
});
});
describe('generateMarkdownReport()', () => {
it('should include summary section', () => {
const results: ExampleResult[] = [];
const summary = createRunSummary({
totalExamples: 10,
passed: 8,
failed: 1,
errors: 1,
averageScore: 0.75,
});
const report = generateMarkdownReport(results, summary);
expect(report).toContain('# AI Workflow Builder Evaluation Report');
expect(report).toContain('## Summary');
expect(report).toContain('Total Tests: 10');
expect(report).toContain('Passed: 8');
expect(report).toContain('Failed: 1');
expect(report).toContain('Errors: 1');
expect(report).toContain('75.0%');
});
it('should include evaluator averages', () => {
const results: ExampleResult[] = [
createExampleResult({
feedback: [
createFeedback('llm-judge', 'a', 0.8),
createFeedback('programmatic', 'b', 0.6),
],
}),
];
const summary = createRunSummary();
const report = generateMarkdownReport(results, summary);
expect(report).toContain('## Evaluator Averages');
expect(report).toContain('llm-judge');
expect(report).toContain('programmatic');
});
it('should include violation summary', () => {
const results: ExampleResult[] = [
createExampleResult({
feedback: [
createFeedback('a', 'b', 0, 'detail', '[CRITICAL] Issue 1'),
createFeedback('a', 'c', 0, 'detail', '[MAJOR] Issue 2'),
],
}),
];
const summary = createRunSummary();
const report = generateMarkdownReport(results, summary);
expect(report).toContain('## Violations Summary');
expect(report).toContain('Critical: 1');
expect(report).toContain('Major: 1');
});
it('should include detailed results when option enabled', () => {
const results: ExampleResult[] = [
createExampleResult({
index: 1,
prompt: 'Create a workflow that sends emails',
status: 'pass',
durationMs: 1500,
feedback: [createFeedback('llm-judge', 'a', 0.9, 'metric', 'Good job')],
}),
];
const summary = createRunSummary();
const report = generateMarkdownReport(results, summary, { includeDetails: true });
expect(report).toContain('## Detailed Results');
expect(report).toContain('### Test 1');
expect(report).toContain('Create a workflow');
expect(report).toContain('pass');
expect(report).toContain('1500ms');
});
it('should not include details when option disabled', () => {
const results: ExampleResult[] = [createExampleResult({ prompt: 'Test prompt here' })];
const summary = createRunSummary();
const report = generateMarkdownReport(results, summary, { includeDetails: false });
expect(report).not.toContain('## Detailed Results');
expect(report).not.toContain('Test prompt here');
});
it('should truncate long prompts in details', () => {
const longPrompt = 'A'.repeat(200);
const results: ExampleResult[] = [createExampleResult({ prompt: longPrompt })];
const summary = createRunSummary();
const report = generateMarkdownReport(results, summary, { includeDetails: true });
expect(report).toContain('...');
expect(report).not.toContain(longPrompt);
});
it('should handle empty results gracefully', () => {
const summary = createRunSummary({ totalExamples: 0, passed: 0, failed: 0, errors: 0 });
const report = generateMarkdownReport([], summary);
expect(report).toContain('# AI Workflow Builder Evaluation Report');
expect(report).toContain('Total Tests: 0');
});
it('should include feedback details in test results', () => {
const results: ExampleResult[] = [
createExampleResult({
feedback: [
createFeedback('llm-judge', 'functionality', 0.9, 'metric', 'Great functionality'),
createFeedback('programmatic', 'trigger', 1.0),
],
}),
];
const summary = createRunSummary();
const report = generateMarkdownReport(results, summary, { includeDetails: true });
expect(report).toContain('llm-judge.functionality');
expect(report).toContain('90.0%');
expect(report).toContain('Great functionality');
});
it('should format pass rate as percentage', () => {
const summary = createRunSummary({ totalExamples: 10, passed: 8 });
const report = generateMarkdownReport([], summary);
expect(report).toContain('80.0%'); // Pass rate
});
});
});
@@ -0,0 +1,736 @@
/**
* Tests for LangSmith mode runner.
*
* These tests mock the LangSmith evaluate() function to verify:
* - Target function does all work (generation + evaluation)
* - Evaluator just extracts pre-computed feedback
* - Dataset context extraction is respected
* - Filters trigger dataset example preloading
*/
import { mock } from 'jest-mock-extended';
import type { Client } from 'langsmith/client';
import { evaluate as langsmithEvaluate } from 'langsmith/evaluation';
import type { Dataset, Example } from 'langsmith/schemas';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { SimpleWorkflow } from '@/types/workflow';
import type { Evaluator, Feedback, RunConfig } from '../harness/harness-types';
import { createLogger } from '../harness/logger';
const silentLogger = createLogger(false);
jest.mock('langsmith/evaluation', () => ({
evaluate: jest.fn().mockResolvedValue({ experimentName: 'test-experiment' }),
}));
jest.mock('langsmith/traceable', () => ({
traceable: jest.fn(
<T extends (...args: unknown[]) => unknown>(fn: T, _options: unknown): T => fn,
),
}));
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
function createMockEvaluator(
name: string,
feedback: Feedback[] = [{ evaluator: name, metric: 'score', score: 1, kind: 'score' }],
): Evaluator {
return {
name,
evaluate: jest.fn().mockResolvedValue(feedback),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isCallable(value: unknown): value is (...args: unknown[]) => unknown {
return typeof value === 'function';
}
type LangsmithTargetOutput = {
workflow: SimpleWorkflow;
prompt: string;
feedback: Feedback[];
};
function isSimpleWorkflow(value: unknown): value is SimpleWorkflow {
return isRecord(value) && Array.isArray(value.nodes) && isRecord(value.connections);
}
function isFeedback(value: unknown): value is Feedback {
return (
isRecord(value) &&
typeof value.evaluator === 'string' &&
typeof value.metric === 'string' &&
typeof value.score === 'number' &&
(value.kind === 'score' || value.kind === 'metric' || value.kind === 'detail')
);
}
function isLangsmithTargetOutput(value: unknown): value is LangsmithTargetOutput {
return (
isRecord(value) &&
isSimpleWorkflow(value.workflow) &&
typeof value.prompt === 'string' &&
Array.isArray(value.feedback) &&
value.feedback.every(isFeedback)
);
}
async function callLangsmithTarget(target: unknown, inputs: unknown): Promise<unknown> {
if (isCallable(target)) return await target(inputs);
if (isRecord(target) && isCallable(target.invoke)) return await target.invoke(inputs);
throw new Error('Expected LangSmith target to be callable');
}
function createMockLangsmithClient() {
const lsClient = mock<Client>();
lsClient.readDataset.mockResolvedValue(mock<Dataset>({ id: 'test-dataset-id' }));
lsClient.listExamples.mockReturnValue((async function* () {})());
lsClient.awaitPendingTraceBatches.mockResolvedValue(undefined);
return lsClient;
}
describe('Runner - LangSmith Mode', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('runEvaluation() with LangSmith', () => {
it('should call langsmith evaluate() with correct options', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const config: RunConfig = {
mode: 'langsmith',
dataset: 'my-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createMockEvaluator('test')],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test-experiment',
repetitions: 2,
concurrency: 4,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [_target, options] = mockEvaluate.mock.calls[0];
expect(options).toEqual(
expect.objectContaining({
data: 'my-dataset',
experimentPrefix: 'test-experiment',
numRepetitions: 2,
maxConcurrency: 4,
client: lsClient,
}),
);
});
it('should create target function that generates workflow and runs evaluators', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const workflow = createMockWorkflow('Generated');
const generateWorkflow = jest.fn().mockResolvedValue(workflow);
const evaluator = createMockEvaluator('test', [
{ evaluator: 'test', metric: 'score', score: 0.9, kind: 'score' },
]);
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow,
evaluators: [evaluator],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [target] = mockEvaluate.mock.calls[0];
const result = await callLangsmithTarget(target, { prompt: 'Create a workflow' });
expect(isLangsmithTargetOutput(result)).toBe(true);
if (!isLangsmithTargetOutput(result)) throw new Error('Expected LangSmith target output');
// Collectors are passed explicitly from the traceable wrapper to capture token usage and subgraph metrics
expect(generateWorkflow).toHaveBeenCalledWith(
'Create a workflow',
expect.objectContaining({
tokenUsage: expect.any(Function),
subgraphMetrics: expect.any(Function),
}),
);
expect(evaluator.evaluate).toHaveBeenCalledWith(
workflow,
expect.objectContaining({ prompt: 'Create a workflow' }),
);
expect(result).toEqual({
workflow,
prompt: 'Create a workflow',
feedback: [{ evaluator: 'test', metric: 'score', score: 0.9, kind: 'score' }],
});
});
it('should write artifacts when outputDir is provided', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'v2-evals-langsmith-out-'));
try {
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
outputDir: tempDir,
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow('Generated')),
evaluators: [createMockEvaluator('test')],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [target] = mockEvaluate.mock.calls[0];
await callLangsmithTarget(target, { prompt: 'Create a workflow' });
const entries = fs.readdirSync(tempDir, { withFileTypes: true });
const exampleDir = entries.find(
(e) => e.isDirectory() && e.name.startsWith('example-001-'),
)?.name;
expect(exampleDir).toBeDefined();
expect(fs.existsSync(path.join(tempDir, exampleDir!, 'prompt.txt'))).toBe(true);
expect(fs.existsSync(path.join(tempDir, exampleDir!, 'workflow.json'))).toBe(true);
expect(fs.existsSync(path.join(tempDir, exampleDir!, 'feedback.json'))).toBe(true);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('should aggregate feedback from multiple evaluators in target', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const evaluator1 = createMockEvaluator('e1', [
{ evaluator: 'e1', metric: 'score', score: 0.8, kind: 'score' },
]);
const evaluator2 = createMockEvaluator('e2', [
{ evaluator: 'e2', metric: 'a', score: 0.9, kind: 'metric' },
{ evaluator: 'e2', metric: 'b', score: 1.0, kind: 'metric' },
]);
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator1, evaluator2],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [target] = mockEvaluate.mock.calls[0];
const result = await callLangsmithTarget(target, { prompt: 'Test' });
expect(isLangsmithTargetOutput(result)).toBe(true);
if (!isLangsmithTargetOutput(result)) throw new Error('Expected LangSmith target output');
expect(result.feedback).toHaveLength(3);
expect(result.feedback).toContainEqual({
evaluator: 'e1',
metric: 'score',
score: 0.8,
kind: 'score',
});
expect(result.feedback).toContainEqual({
evaluator: 'e2',
metric: 'a',
score: 0.9,
kind: 'metric',
});
expect(result.feedback).toContainEqual({
evaluator: 'e2',
metric: 'b',
score: 1.0,
kind: 'metric',
});
});
it('should handle evaluator errors gracefully in target', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const goodEvaluator = createMockEvaluator('good', [
{ evaluator: 'good', metric: 'score', score: 1, kind: 'score' },
]);
const badEvaluator: Evaluator = {
name: 'bad',
evaluate: jest.fn().mockRejectedValue(new Error('Evaluator crashed')),
};
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [goodEvaluator, badEvaluator],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [target] = mockEvaluate.mock.calls[0];
const result = await callLangsmithTarget(target, { prompt: 'Test' });
expect(isLangsmithTargetOutput(result)).toBe(true);
if (!isLangsmithTargetOutput(result)) throw new Error('Expected LangSmith target output');
expect(result.feedback).toContainEqual({
evaluator: 'good',
metric: 'score',
score: 1,
kind: 'score',
});
expect(result.feedback).toContainEqual({
evaluator: 'bad',
metric: 'error',
score: 0,
kind: 'score',
comment: 'Evaluator crashed',
});
});
it('should create evaluator that extracts pre-computed feedback', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createMockEvaluator('test')],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [_target, options] = mockEvaluate.mock.calls[0];
expect(Array.isArray(options.evaluators)).toBe(true);
if (!Array.isArray(options.evaluators))
throw new Error('Expected LangSmith evaluators array');
expect(options.evaluators).toHaveLength(1);
const evaluatorFn = options.evaluators[0];
expect(isCallable(evaluatorFn)).toBe(true);
if (!isCallable(evaluatorFn)) throw new Error('Expected evaluator function');
const extracted = await evaluatorFn({
outputs: {
feedback: [
{ evaluator: 'test', metric: 'score', score: 0.9, kind: 'score' },
{ evaluator: 'other', metric: 'trigger', score: 0.8, kind: 'metric' },
],
},
});
expect(extracted).toEqual([
{ key: 'test.score', score: 0.9 },
{ key: 'other.trigger', score: 0.8 },
]);
});
it('should keep programmatic prefixes but not llm-judge metric prefixes', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createMockEvaluator('test')],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [_target, options] = mockEvaluate.mock.calls[0];
expect(Array.isArray(options.evaluators)).toBe(true);
if (!Array.isArray(options.evaluators))
throw new Error('Expected LangSmith evaluators array');
const evaluatorFn = options.evaluators[0];
expect(isCallable(evaluatorFn)).toBe(true);
if (!isCallable(evaluatorFn)) throw new Error('Expected evaluator function');
const extracted = await evaluatorFn({
outputs: {
feedback: [
{ evaluator: 'llm-judge', metric: 'functionality', score: 0.9, kind: 'metric' },
{ evaluator: 'programmatic', metric: 'trigger', score: 0.8, kind: 'metric' },
{
evaluator: 'llm-judge',
metric: 'maintainability.nodeNamingQuality',
score: 0.7,
kind: 'detail',
},
],
},
});
expect(extracted).toEqual([
{ key: 'functionality', score: 0.9 },
{ key: 'programmatic.trigger', score: 0.8 },
{ key: 'maintainability.nodeNamingQuality', score: 0.7 },
]);
});
it('should handle missing feedback in outputs', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [_target, options] = mockEvaluate.mock.calls[0];
expect(Array.isArray(options.evaluators)).toBe(true);
if (!Array.isArray(options.evaluators))
throw new Error('Expected LangSmith evaluators array');
const evaluatorFn = options.evaluators[0];
expect(isCallable(evaluatorFn)).toBe(true);
if (!isCallable(evaluatorFn)) throw new Error('Expected evaluator function');
const extracted = await evaluatorFn({ outputs: {} });
expect(extracted).toEqual([
{
key: 'evaluationError',
score: 0,
comment: 'No feedback found in target output',
},
]);
});
it('should pass dataset-level context to evaluators', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const evaluateContextual: Evaluator['evaluate'] = async (_workflow, ctx) => [
{ evaluator: 'contextual', metric: 'score', score: ctx.dos ? 1 : 0, kind: 'score' },
];
const evaluator: Evaluator = {
name: 'contextual',
evaluate: jest.fn(evaluateContextual),
};
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [target] = mockEvaluate.mock.calls[0];
const result = await callLangsmithTarget(target, {
prompt: 'Test',
evals: { dos: 'Use Slack', donts: 'No HTTP' },
});
expect(isLangsmithTargetOutput(result)).toBe(true);
if (!isLangsmithTargetOutput(result)) throw new Error('Expected LangSmith target output');
expect(evaluator.evaluate).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ dos: 'Use Slack', donts: 'No HTTP' }),
);
expect(result.feedback).toContainEqual({
evaluator: 'contextual',
metric: 'score',
score: 1,
kind: 'score',
});
});
it('should ignore invalid referenceWorkflow in dataset context', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const evaluate = jest.fn<
ReturnType<Evaluator['evaluate']>,
Parameters<Evaluator['evaluate']>
>(async (_workflow, ctx) => [
{
evaluator: 'ref-check',
metric: 'hasRef',
score: ctx.referenceWorkflows && ctx.referenceWorkflows.length > 0 ? 1 : 0,
kind: 'score',
},
]);
const evaluator: Evaluator = {
name: 'ref-check',
evaluate,
};
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [target] = mockEvaluate.mock.calls[0];
const result = await callLangsmithTarget(target, {
prompt: 'Test',
evals: {
referenceWorkflow: { nodes: [{}], connections: {} },
},
});
expect(isLangsmithTargetOutput(result)).toBe(true);
if (!isLangsmithTargetOutput(result)) throw new Error('Expected LangSmith target output');
const ctx = evaluate.mock.calls[0]?.[1];
expect(ctx?.referenceWorkflows).toBeUndefined();
expect(result.feedback).toContainEqual({
evaluator: 'ref-check',
metric: 'hasRef',
score: 0,
kind: 'score',
});
});
it('should pre-load and filter examples when filters are provided', async () => {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const examples: Example[] = [
mock<Example>({
id: 'e1',
inputs: { prompt: 'One', evals: { dos: 'Use Slack', donts: 'No HTTP' } },
metadata: { notion_id: 'n1', categories: ['data_transformation'] },
}),
mock<Example>({
id: 'e2',
inputs: { prompt: 'Two', evals: { dos: 'Use Gmail', donts: 'No Slack' } },
metadata: { notion_id: 'n2', categories: ['other'] },
}),
];
const lsClient = createMockLangsmithClient();
lsClient.listExamples.mockReturnValue(
(async function* () {
for (const ex of examples) yield ex;
})(),
);
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createMockEvaluator('test')],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
filters: { notionId: 'n1', technique: 'data_transformation', doSearch: 'slack' },
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(mockEvaluate).toHaveBeenCalledTimes(1);
const [_target, options] = mockEvaluate.mock.calls[0];
const data: unknown = options.data;
expect(Array.isArray(data)).toBe(true);
if (!Array.isArray(data)) throw new Error('Expected `evaluate()` to receive example array');
const ids = data
.filter((e): e is { id: string } => isRecord(e) && typeof e.id === 'string')
.map((e) => e.id);
expect(ids).toEqual(['e1']);
});
it('should throw when filters match no examples', async () => {
const lsClient = createMockLangsmithClient();
lsClient.listExamples.mockReturnValue(
(async function* () {
yield mock<Example>({
id: 'e1',
inputs: { prompt: 'One', evals: { dos: 'Use Slack', donts: 'No HTTP' } },
metadata: { notion_id: 'n1', categories: ['data_transformation'] },
});
})(),
);
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createMockEvaluator('test')],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
filters: { notionId: 'does-not-exist' },
},
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await expect(runEvaluation(config)).rejects.toThrow('No examples matched filters');
});
it('should include evaluatorAverages in summary', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'eval-test-'));
try {
const mockEvaluate = jest.mocked(langsmithEvaluate);
const lsClient = createMockLangsmithClient();
const evaluator1 = createMockEvaluator('pairwise', [
{ evaluator: 'pairwise', metric: 'score', score: 0.8, kind: 'score' },
]);
const evaluator2 = createMockEvaluator('programmatic', [
{ evaluator: 'programmatic', metric: 'overall', score: 0.9, kind: 'score' },
]);
// Mock evaluate to call the target function with test inputs
mockEvaluate.mockImplementationOnce(async (target, _options) => {
// Call the target to populate capturedResults
await callLangsmithTarget(target, { prompt: 'Test prompt 1' });
await callLangsmithTarget(target, { prompt: 'Test prompt 2' });
return { experimentName: 'test-experiment' } as Awaited<
ReturnType<typeof langsmithEvaluate>
>;
});
const config: RunConfig = {
mode: 'langsmith',
dataset: 'test-dataset',
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator1, evaluator2],
langsmithClient: lsClient,
langsmithOptions: {
experimentName: 'test',
repetitions: 1,
concurrency: 1,
},
outputDir: tempDir, // Enable artifact saving to capture results
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
const summary = await runEvaluation(config);
// The summary should include evaluatorAverages computed from captured results
expect(summary.evaluatorAverages).toBeDefined();
expect(summary.evaluatorAverages).toEqual({
pairwise: 0.8,
programmatic: 0.9,
});
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
});
@@ -0,0 +1,437 @@
import type { SimpleWorkflow } from '@/types/workflow';
import type {
Evaluator,
TestCase,
Feedback,
RunConfig,
EvaluationLifecycle,
ExampleResult,
} from '../harness/harness-types';
import { createLogger } from '../harness/logger';
const silentLogger = createLogger(false);
/** Helper to create a minimal valid workflow for tests */
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
return { name, nodes: [], connections: {} };
}
/** Helper to create a simple evaluator */
function createMockEvaluator(
name: string,
feedback: Feedback[] = [{ evaluator: name, metric: 'score', score: 1, kind: 'score' }],
): Evaluator {
return {
name,
evaluate: jest.fn().mockResolvedValue(feedback),
};
}
/** Helper to create a failing evaluator */
function createFailingEvaluator(name: string, error: Error): Evaluator {
return {
name,
evaluate: jest.fn().mockRejectedValue(error),
};
}
describe('Runner - Local Mode', () => {
describe('runEvaluation()', () => {
it('should process all test cases sequentially', async () => {
const testCases: TestCase[] = [
{ prompt: 'Create workflow A' },
{ prompt: 'Create workflow B' },
{ prompt: 'Create workflow C' },
];
const generateWorkflow = jest.fn().mockResolvedValue(createMockWorkflow());
const evaluator = createMockEvaluator('test');
const config: RunConfig = {
mode: 'local',
dataset: testCases,
generateWorkflow,
evaluators: [evaluator],
logger: silentLogger,
};
// Import dynamically to avoid circular deps in test setup
const { runEvaluation } = await import('../harness/runner');
const summary = await runEvaluation(config);
expect(generateWorkflow).toHaveBeenCalledTimes(3);
expect(evaluator.evaluate).toHaveBeenCalledTimes(3);
expect(summary.totalExamples).toBe(3);
});
it('should run all evaluators in parallel for each example', async () => {
const evaluator1 = createMockEvaluator('eval1', [
{ evaluator: 'eval1', metric: 'score', score: 0.8, kind: 'score' },
]);
const evaluator2 = createMockEvaluator('eval2', [
{ evaluator: 'eval2', metric: 'score', score: 0.9, kind: 'score' },
]);
const evaluator3 = createMockEvaluator('eval3', [
{ evaluator: 'eval3', metric: 'score', score: 1.0, kind: 'score' },
]);
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator1, evaluator2, evaluator3],
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
const summary = await runEvaluation(config);
// All evaluators should be called
expect(evaluator1.evaluate).toHaveBeenCalledTimes(1);
expect(evaluator2.evaluate).toHaveBeenCalledTimes(1);
expect(evaluator3.evaluate).toHaveBeenCalledTimes(1);
// Average score should be (0.8 + 0.9 + 1.0) / 3 = 0.9
expect(summary.averageScore).toBeCloseTo(0.9, 2);
});
it('should skip and continue when evaluator throws error', async () => {
const goodEvaluator = createMockEvaluator('good', [
{ evaluator: 'good', metric: 'score', score: 1.0, kind: 'score' },
]);
const badEvaluator = createFailingEvaluator('bad', new Error('Evaluator crashed'));
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [goodEvaluator, badEvaluator],
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
const summary = await runEvaluation(config);
// Should complete despite error
expect(summary.totalExamples).toBe(1);
// Good evaluator should still run
expect(goodEvaluator.evaluate).toHaveBeenCalled();
});
it('should skip and continue when workflow generation fails', async () => {
const generateWorkflow = jest
.fn()
.mockResolvedValueOnce(createMockWorkflow())
.mockRejectedValueOnce(new Error('Generation failed'))
.mockResolvedValueOnce(createMockWorkflow());
const evaluator = createMockEvaluator('test');
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test 1' }, { prompt: 'Test 2' }, { prompt: 'Test 3' }],
generateWorkflow,
evaluators: [evaluator],
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
const summary = await runEvaluation(config);
expect(summary.totalExamples).toBe(3);
expect(summary.errors).toBe(1);
expect(summary.passed + summary.failed).toBe(2);
});
it('should pass context from test case to evaluators', async () => {
const evaluate: Evaluator['evaluate'] = async (_workflow, ctx) => {
expect(ctx.dos).toBe('Use Slack');
expect(ctx.donts).toBe('No HTTP');
return [{ evaluator: 'contextual', metric: 'score', score: 1, kind: 'score' }];
};
const evaluator: Evaluator = {
name: 'contextual',
evaluate: jest.fn(evaluate),
};
const config: RunConfig = {
mode: 'local',
dataset: [
{
prompt: 'Test',
context: { dos: 'Use Slack', donts: 'No HTTP' },
},
],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator],
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(evaluator.evaluate).toHaveBeenCalled();
});
it('should merge global context with test case context', async () => {
const evaluate: Evaluator['evaluate'] = async (_workflow, ctx) => {
expect(ctx.dos).toBe('Use Slack');
expect(ctx.donts).toBe('No HTTP');
return [{ evaluator: 'merged', metric: 'score', score: 1, kind: 'score' }];
};
const evaluator: Evaluator = {
name: 'merged',
evaluate: jest.fn(evaluate),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test', context: { donts: 'No HTTP' } }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator],
context: { dos: 'Use Slack' },
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(evaluator.evaluate).toHaveBeenCalled();
});
it('should calculate pass/fail status based on threshold', async () => {
const highScoreEvaluator = createMockEvaluator('high', [
{ evaluator: 'high', metric: 'score', score: 0.9, kind: 'score' },
]);
const lowScoreEvaluator = createMockEvaluator('low', [
{ evaluator: 'low', metric: 'score', score: 0.3, kind: 'score' },
]);
// High score should pass (>= 0.7 threshold)
const config1: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [highScoreEvaluator],
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
const summary1 = await runEvaluation(config1);
expect(summary1.passed).toBe(1);
// Low score should fail
const config2: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [lowScoreEvaluator],
logger: silentLogger,
};
const summary2 = await runEvaluation(config2);
expect(summary2.failed).toBe(1);
});
it('should aggregate feedback from all evaluators', async () => {
const evaluator1 = createMockEvaluator('e1', [
{ evaluator: 'e1', metric: 'func', score: 0.8, kind: 'metric' },
{ evaluator: 'e1', metric: 'conn', score: 0.9, kind: 'metric' },
]);
const evaluator2 = createMockEvaluator('e2', [
{ evaluator: 'e2', metric: 'overall', score: 0.85, kind: 'score' },
]);
const collected: ExampleResult[] = [];
const lifecycle: Partial<EvaluationLifecycle> = {
onExampleComplete: (_index, result) => collected.push(result),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [evaluator1, evaluator2],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(collected).toHaveLength(1);
expect(collected[0].feedback).toHaveLength(3); // 2 from e1 + 1 from e2
});
});
describe('Lifecycle Hooks', () => {
it('should call onStart at beginning of run', async () => {
const lifecycle: Partial<EvaluationLifecycle> = {
onStart: jest.fn(),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(lifecycle.onStart).toHaveBeenCalledWith(config);
});
it('should call onExampleStart before each example', async () => {
const lifecycle: Partial<EvaluationLifecycle> = {
onExampleStart: jest.fn(),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test 1' }, { prompt: 'Test 2' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(lifecycle.onExampleStart).toHaveBeenCalledTimes(2);
expect(lifecycle.onExampleStart).toHaveBeenNthCalledWith(1, 1, 2, 'Test 1');
expect(lifecycle.onExampleStart).toHaveBeenNthCalledWith(2, 2, 2, 'Test 2');
});
it('should call onWorkflowGenerated after generation', async () => {
const workflow = createMockWorkflow('Generated');
const lifecycle: Partial<EvaluationLifecycle> = {
onWorkflowGenerated: jest.fn(),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(workflow),
evaluators: [],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(lifecycle.onWorkflowGenerated).toHaveBeenCalledWith(
workflow,
expect.any(Number), // durationMs
);
});
it('should call onEvaluatorComplete after each evaluator', async () => {
const lifecycle: Partial<EvaluationLifecycle> = {
onEvaluatorComplete: jest.fn(),
};
const feedback1: Feedback[] = [
{ evaluator: 'eval1', metric: 'score', score: 0.8, kind: 'score' },
];
const feedback2: Feedback[] = [
{ evaluator: 'eval2', metric: 'score', score: 0.9, kind: 'score' },
];
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [
createMockEvaluator('eval1', feedback1),
createMockEvaluator('eval2', feedback2),
],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(lifecycle.onEvaluatorComplete).toHaveBeenCalledTimes(2);
expect(lifecycle.onEvaluatorComplete).toHaveBeenCalledWith('eval1', feedback1);
expect(lifecycle.onEvaluatorComplete).toHaveBeenCalledWith('eval2', feedback2);
});
it('should call onEvaluatorError when evaluator fails', async () => {
const error = new Error('Evaluator crashed');
const lifecycle: Partial<EvaluationLifecycle> = {
onEvaluatorError: jest.fn(),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createFailingEvaluator('failing', error)],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(lifecycle.onEvaluatorError).toHaveBeenCalledWith('failing', error);
});
it('should call onExampleComplete after each example', async () => {
const lifecycle: Partial<EvaluationLifecycle> = {
onExampleComplete: jest.fn(),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createMockEvaluator('test')],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
await runEvaluation(config);
expect(lifecycle.onExampleComplete).toHaveBeenCalledWith(
1,
expect.objectContaining({
index: 1,
prompt: 'Test',
status: 'pass',
}),
);
});
it('should call onEnd with summary at end of run', async () => {
const lifecycle: Partial<EvaluationLifecycle> = {
onEnd: jest.fn(),
};
const config: RunConfig = {
mode: 'local',
dataset: [{ prompt: 'Test' }],
generateWorkflow: jest.fn().mockResolvedValue(createMockWorkflow()),
evaluators: [createMockEvaluator('test')],
lifecycle,
logger: silentLogger,
};
const { runEvaluation } = await import('../harness/runner');
const summary = await runEvaluation(config);
expect(lifecycle.onEnd).toHaveBeenCalledWith(summary);
});
});
});
@@ -0,0 +1,350 @@
/**
* Tests for score calculation utilities.
*
* These utilities calculate weighted scores and aggregate feedback
* from multiple evaluators.
*/
import type { Feedback } from '../harness/harness-types';
import {
parseFeedbackKey,
extractCategory,
groupByEvaluator,
calculateWeightedScore,
aggregateScores,
DEFAULT_EVALUATOR_WEIGHTS,
} from '../harness/score-calculator';
/** Helper to create feedback items */
function createFeedback(
evaluator: string,
metric: string,
score: number,
kind: Feedback['kind'] = 'metric',
comment?: string,
): Feedback {
return { evaluator, metric, score, kind, ...(comment ? { comment } : {}) };
}
describe('Score Calculator', () => {
describe('parseFeedbackKey()', () => {
it('should parse two-part key', () => {
const result = parseFeedbackKey('llm-judge.functionality');
expect(result).toEqual({
evaluator: 'llm-judge',
category: 'functionality',
subcategory: undefined,
});
});
it('should parse three-part key', () => {
const result = parseFeedbackKey('pairwise.gen1.majorityPass');
expect(result).toEqual({
evaluator: 'pairwise',
category: 'gen1',
subcategory: 'majorityPass',
});
});
it('should handle single-part key', () => {
const result = parseFeedbackKey('overall');
expect(result).toEqual({
evaluator: 'overall',
category: '',
subcategory: undefined,
});
});
it('should handle keys with more than three parts', () => {
const result = parseFeedbackKey('a.b.c.d.e');
expect(result).toEqual({
evaluator: 'a',
category: 'b',
subcategory: 'c',
});
});
});
describe('extractCategory()', () => {
it('should extract category from llm-judge key', () => {
expect(extractCategory('llm-judge.functionality')).toBe('functionality');
});
it('should extract category from programmatic key', () => {
expect(extractCategory('programmatic.trigger')).toBe('trigger');
});
it('should extract category from pairwise key', () => {
expect(extractCategory('pairwise.majorityPass')).toBe('majorityPass');
});
it('should return empty string for single-part key', () => {
expect(extractCategory('overall')).toBe('');
});
it('should extract first category from multi-part key', () => {
expect(extractCategory('pairwise.gen1.diagnosticScore')).toBe('gen1');
});
});
describe('groupByEvaluator()', () => {
it('should group feedback by evaluator prefix', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'functionality', 0.8),
createFeedback('llm-judge', 'connections', 0.9),
createFeedback('programmatic', 'trigger', 1.0),
];
const grouped = groupByEvaluator(feedback);
expect(Object.keys(grouped)).toHaveLength(2);
expect(grouped['llm-judge']).toHaveLength(2);
expect(grouped['programmatic']).toHaveLength(1);
});
it('should handle mixed evaluators', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'a', 0.5),
createFeedback('programmatic', 'b', 0.6),
createFeedback('pairwise', 'c', 0.7),
createFeedback('similarity', 'd', 0.8),
];
const grouped = groupByEvaluator(feedback);
expect(Object.keys(grouped)).toHaveLength(4);
expect(grouped['llm-judge']).toHaveLength(1);
expect(grouped['programmatic']).toHaveLength(1);
expect(grouped['pairwise']).toHaveLength(1);
expect(grouped['similarity']).toHaveLength(1);
});
it('should handle empty array', () => {
const grouped = groupByEvaluator([]);
expect(grouped).toEqual({});
});
it('should preserve feedback properties', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'test', 0.75, 'metric', 'Test comment'),
];
const grouped = groupByEvaluator(feedback);
expect(grouped['llm-judge'][0]).toEqual({
evaluator: 'llm-judge',
metric: 'test',
score: 0.75,
kind: 'metric',
comment: 'Test comment',
});
});
});
describe('calculateWeightedScore()', () => {
it('should use default weights', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'a', 1.0),
createFeedback('programmatic', 'b', 0.5),
createFeedback('pairwise', 'c', 0.5),
createFeedback('similarity', 'd', 0.5),
];
const score = calculateWeightedScore(feedback);
// llm-judge: 1.0 * 0.35 = 0.35
// programmatic: 0.5 * 0.25 = 0.125
// pairwise: 0.5 * 0.25 = 0.125
// similarity: 0.5 * 0.15 = 0.075
// Total: 0.675 / 1.0 = 0.675
expect(score).toBeCloseTo(0.675);
});
it('should use custom weights', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'a', 1.0),
createFeedback('programmatic', 'b', 0.0),
];
const score = calculateWeightedScore(feedback, {
'llm-judge': 0.8,
programmatic: 0.2,
});
// llm-judge: 1.0 * 0.8 = 0.8
// programmatic: 0.0 * 0.2 = 0.0
// Total: 0.8 / 1.0 = 0.8
expect(score).toBeCloseTo(0.8);
});
it('should handle missing evaluators with default weight', () => {
const feedback: Feedback[] = [createFeedback('unknown-evaluator', 'a', 0.5)];
const score = calculateWeightedScore(feedback);
// unknown-evaluator gets default weight of 0.1
expect(score).toBeCloseTo(0.5);
});
it('should return 0 for empty feedback', () => {
const score = calculateWeightedScore([]);
expect(score).toBe(0);
});
it('should average multiple items from same evaluator', () => {
const feedback: Feedback[] = [
{ ...createFeedback('llm-judge', 'a', 1.0), kind: 'metric' },
{ ...createFeedback('llm-judge', 'b', 0.5), kind: 'metric' },
{ ...createFeedback('llm-judge', 'c', 0.5), kind: 'metric' },
];
const score = calculateWeightedScore(feedback, { 'llm-judge': 1.0 });
// avg(1.0, 0.5, 0.5) = 0.666...
expect(score).toBeCloseTo(0.666, 2);
});
it('should ignore detail items when score items exist', () => {
const feedback: Feedback[] = [
{ evaluator: 'llm-judge', metric: 'overallScore', score: 0.8, kind: 'score' },
{
evaluator: 'llm-judge',
metric: 'efficiency.nodeCountEfficiency',
score: 0.0,
kind: 'detail',
},
{
evaluator: 'llm-judge',
metric: 'efficiency.pathOptimization',
score: 0.0,
kind: 'detail',
},
];
expect(calculateWeightedScore(feedback)).toBeCloseTo(0.8, 5);
});
it('should be invariant to extra detail keys', () => {
const base: Feedback[] = [
{ evaluator: 'pairwise', metric: 'pairwise_primary', score: 1, kind: 'score' },
];
const withDetails: Feedback[] = [
...base,
{ evaluator: 'pairwise', metric: 'judge1', score: 0, kind: 'detail' },
{ evaluator: 'pairwise', metric: 'judge2', score: 0, kind: 'detail' },
];
expect(calculateWeightedScore(base)).toBeCloseTo(calculateWeightedScore(withDetails), 10);
});
it('should normalize weights', () => {
const feedback: Feedback[] = [createFeedback('a', 'x', 1.0), createFeedback('b', 'x', 0.0)];
// Weights don't sum to 1.0
const score = calculateWeightedScore(feedback, {
a: 0.5,
b: 0.5,
});
// a: 1.0 * 0.5 = 0.5
// b: 0.0 * 0.5 = 0.0
// Total: 0.5 / 1.0 = 0.5
expect(score).toBeCloseTo(0.5);
});
});
describe('aggregateScores()', () => {
it('should calculate overall score', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'a', 0.8),
createFeedback('programmatic', 'b', 0.6),
];
const result = aggregateScores(feedback);
// llm-judge: 0.8 * 0.4 = 0.32
// programmatic: 0.6 * 0.3 = 0.18
// Total weight: 0.7, Total: 0.5 / 0.7 = 0.714...
expect(result.overall).toBeCloseTo(0.714, 2);
});
it('should calculate by-evaluator averages', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'a', 0.8),
createFeedback('llm-judge', 'b', 0.6),
createFeedback('programmatic', 'c', 1.0),
];
const result = aggregateScores(feedback);
expect(result.byEvaluator['llm-judge']).toBeCloseTo(0.7); // (0.8 + 0.6) / 2
expect(result.byEvaluator['programmatic']).toBeCloseTo(1.0);
});
it('should calculate by-category averages', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'functionality', 0.8),
createFeedback('llm-judge', 'connections', 0.6),
createFeedback('programmatic', 'trigger', 1.0),
];
const result = aggregateScores(feedback);
expect(result.byCategory['functionality']).toBeCloseTo(0.8);
expect(result.byCategory['connections']).toBeCloseTo(0.6);
expect(result.byCategory['trigger']).toBeCloseTo(1.0);
});
it('should average same categories from different evaluators', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'functionality', 0.8),
createFeedback('programmatic', 'functionality', 0.6),
];
const result = aggregateScores(feedback);
expect(result.byCategory['functionality']).toBeCloseTo(0.7); // (0.8 + 0.6) / 2
});
it('should handle empty feedback', () => {
const result = aggregateScores([]);
expect(result.overall).toBe(0);
expect(result.byEvaluator).toEqual({});
expect(result.byCategory).toEqual({});
});
});
describe('DEFAULT_EVALUATOR_WEIGHTS', () => {
it('should have weights for standard evaluators', () => {
expect(DEFAULT_EVALUATOR_WEIGHTS['llm-judge']).toBeDefined();
expect(DEFAULT_EVALUATOR_WEIGHTS['programmatic']).toBeDefined();
expect(DEFAULT_EVALUATOR_WEIGHTS['pairwise']).toBeDefined();
});
it('should have weights that sum to approximately 1', () => {
const sum = Object.values(DEFAULT_EVALUATOR_WEIGHTS).reduce((a, b) => a + b, 0);
expect(sum).toBeCloseTo(1.0);
});
});
describe('binary-checks weight', () => {
it('binary-checks evaluator has weight 0 in DEFAULT_EVALUATOR_WEIGHTS', () => {
expect(DEFAULT_EVALUATOR_WEIGHTS['binary-checks']).toBe(0);
});
it('binary-checks feedback does not affect weighted score', () => {
const feedback: Feedback[] = [
createFeedback('llm-judge', 'functionality', 0.8),
createFeedback('binary-checks', 'has_nodes', 1),
createFeedback('binary-checks', 'has_trigger', 0),
];
const scoreWithBinary = calculateWeightedScore(feedback);
const feedbackWithout: Feedback[] = [createFeedback('llm-judge', 'functionality', 0.8)];
const scoreWithout = calculateWeightedScore(feedbackWithout);
expect(scoreWithBinary).toBe(scoreWithout);
});
});
});
@@ -0,0 +1,273 @@
/**
* Tests for test case generation.
*
* These utilities generate test cases for workflow evaluation,
* either via LLM or from CSV fixtures.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import { loadDefaultTestCases } from '../cli/csv-prompt-loader';
import { createTestCaseGenerator, type GeneratedTestCase } from '../support/test-case-generator';
/** Type guard for message objects with content */
function isMessageWithContent(msg: unknown): msg is { content: unknown } {
return msg !== null && typeof msg === 'object' && 'content' in msg;
}
/** Type guard for objects with _getType method */
function hasGetTypeMethod(msg: unknown): msg is { _getType: () => string } {
if (msg === null || typeof msg !== 'object') return false;
if (!('_getType' in msg)) return false;
const obj = msg as { _getType: unknown };
return typeof obj._getType === 'function';
}
/** Helper to extract messages from mock invoke calls */
function getMessagesFromMockCall(mockInvoke: jest.Mock): { system: string; human: string } {
const calls = mockInvoke.mock.calls;
if (calls.length === 0) throw new Error('No calls recorded');
const firstCall = calls[0];
if (!Array.isArray(firstCall) || firstCall.length === 0) {
throw new Error('First call has no arguments');
}
const messages = firstCall[0];
if (!Array.isArray(messages) || messages.length < 2) {
throw new Error('Messages array invalid');
}
const systemMsg = messages[0];
const humanMsg = messages[1];
// Type-safe content extraction
const getContent = (msg: unknown): string => {
if (isMessageWithContent(msg)) {
const content = msg.content;
if (typeof content === 'string') return content;
}
return '';
};
return {
system: getContent(systemMsg),
human: getContent(humanMsg),
};
}
describe('Test Case Generator', () => {
describe('createTestCaseGenerator()', () => {
let mockLlm: BaseChatModel;
let mockInvoke: jest.Mock;
beforeEach(() => {
mockInvoke = jest.fn().mockResolvedValue({ testCases: [] });
mockLlm = mock<BaseChatModel>();
(mockLlm as unknown as { withStructuredOutput: jest.Mock }).withStructuredOutput = jest
.fn()
.mockReturnValue({ invoke: mockInvoke });
});
it('should return generator with generate method', () => {
const generator = createTestCaseGenerator(mockLlm);
expect(generator).toHaveProperty('generate');
expect(typeof generator.generate).toBe('function');
});
it('should call LLM with structured output', async () => {
const generator = createTestCaseGenerator(mockLlm);
await generator.generate();
expect(
(mockLlm as unknown as { withStructuredOutput: jest.Mock }).withStructuredOutput,
).toHaveBeenCalled();
expect(mockInvoke).toHaveBeenCalled();
});
it('should include count in generated prompt', async () => {
const generator = createTestCaseGenerator(mockLlm, { count: 20 });
await generator.generate();
const { human } = getMessagesFromMockCall(mockInvoke);
expect(human).toContain('20');
});
it('should use default count of 10', async () => {
const generator = createTestCaseGenerator(mockLlm);
await generator.generate();
const { human } = getMessagesFromMockCall(mockInvoke);
expect(human).toContain('10');
});
it('should include custom focus in generated prompt', async () => {
const generator = createTestCaseGenerator(mockLlm, {
focus: 'API integrations only',
});
await generator.generate();
const { human } = getMessagesFromMockCall(mockInvoke);
expect(human).toContain('API integrations only');
});
it('should return properly typed test cases', async () => {
const mockTestCases: GeneratedTestCase[] = [
{
id: 'test_001',
name: 'Email Automation',
summary: 'Sends automated emails',
prompt: 'Create a workflow that sends emails',
},
{
id: 'test_002',
name: 'Data Processing',
summary: 'Processes CSV data',
prompt: 'Create a workflow that processes CSV files',
},
];
mockInvoke.mockResolvedValue({ testCases: mockTestCases });
const generator = createTestCaseGenerator(mockLlm);
const result = await generator.generate();
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
id: 'test_001',
name: 'Email Automation',
summary: 'Sends automated emails',
prompt: 'Create a workflow that sends emails',
});
});
it('should handle LLM errors gracefully', async () => {
mockInvoke.mockRejectedValue(new Error('LLM error'));
const generator = createTestCaseGenerator(mockLlm);
await expect(generator.generate()).rejects.toThrow('LLM error');
});
it('should use complexity option in focus', async () => {
const generator = createTestCaseGenerator(mockLlm, { complexity: 'complex' });
await generator.generate();
const { human } = getMessagesFromMockCall(mockInvoke);
expect(human.toLowerCase()).toContain('complex');
});
it('should use simple complexity focus', async () => {
const generator = createTestCaseGenerator(mockLlm, { complexity: 'simple' });
await generator.generate();
const { human } = getMessagesFromMockCall(mockInvoke);
expect(human.toLowerCase()).toContain('simple');
});
it('should include system prompt in messages', async () => {
const generator = createTestCaseGenerator(mockLlm);
await generator.generate();
const calls = mockInvoke.mock.calls;
expect(calls.length).toBeGreaterThan(0);
// Extract messages from mock calls with proper type narrowing
const firstCall = calls[0];
if (!Array.isArray(firstCall) || firstCall.length === 0) {
throw new Error('Expected firstCall to be a non-empty array');
}
const firstArg = firstCall[0];
if (!Array.isArray(firstArg)) {
throw new Error('Expected first argument to be an array');
}
const messages = firstArg;
expect(messages).toHaveLength(2);
// Verify message types using type guard
const systemMsg = messages[0];
const humanMsg = messages[1];
expect(hasGetTypeMethod(systemMsg)).toBe(true);
expect(hasGetTypeMethod(humanMsg)).toBe(true);
if (hasGetTypeMethod(systemMsg)) {
expect(systemMsg._getType()).toBe('system');
}
if (hasGetTypeMethod(humanMsg)) {
expect(humanMsg._getType()).toBe('human');
}
});
});
describe('loadDefaultTestCases', () => {
it('should have at least 5 test cases', () => {
const defaultCases = loadDefaultTestCases();
expect(defaultCases.length).toBeGreaterThanOrEqual(5);
});
it('should have required properties on each test case', () => {
const defaultCases = loadDefaultTestCases();
for (const testCase of defaultCases) {
expect(testCase).toHaveProperty('id');
expect(testCase).toHaveProperty('prompt');
expect(typeof testCase.id).toBe('string');
expect(typeof testCase.prompt).toBe('string');
expect(testCase.id!.length).toBeGreaterThan(0);
expect(testCase.prompt.length).toBeGreaterThan(0);
}
});
it('should have unique IDs', () => {
const defaultCases = loadDefaultTestCases();
const ids = defaultCases.map((tc) => tc.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should cover different workflow types', () => {
const defaultCases = loadDefaultTestCases();
const prompts = defaultCases.map((tc) => tc.prompt.toLowerCase());
// Check for variety in test cases
const hasEmail = prompts.some((p) => p.includes('email'));
const hasApi = prompts.some((p) => p.includes('api') || p.includes('webhook'));
const hasData = prompts.some((p) => p.includes('data') || p.includes('process'));
expect(hasEmail || hasApi || hasData).toBe(true);
});
it('should have meaningful prompts', () => {
const defaultCases = loadDefaultTestCases();
for (const testCase of defaultCases) {
// Prompts should be descriptive enough
expect(testCase.prompt.length).toBeGreaterThan(20);
}
});
});
describe('generated test cases', () => {
it('should be compatible with v2 TestCase format', async () => {
const mockTestCases: GeneratedTestCase[] = [
{
id: 'gen_001',
name: 'Generated Test',
summary: 'A generated test case',
prompt: 'Create a workflow',
},
];
const mockInvoke = jest.fn().mockResolvedValue({ testCases: mockTestCases });
const mockLlm = mock<BaseChatModel>();
(mockLlm as unknown as { withStructuredOutput: jest.Mock }).withStructuredOutput = jest
.fn()
.mockReturnValue({ invoke: mockInvoke });
const generator = createTestCaseGenerator(mockLlm);
const generated = await generator.generate();
// Generated test cases should have id and prompt (compatible with v2 TestCase)
expect(generated[0].id).toBe('gen_001');
expect(generated[0].prompt).toBe('Create a workflow');
});
});
});
@@ -0,0 +1,47 @@
/**
* Tests for LangSmith trace filters.
*/
import type { KVMap } from 'langsmith/schemas';
import type { EvalLogger } from '../harness/logger';
import { createTraceFilters } from '../langsmith/trace-filters';
describe('trace-filters', () => {
it('should not trim messages (keeps array) while still filtering other large state fields', () => {
const logs: string[] = [];
const logger: EvalLogger = {
isVerbose: true,
info: (m) => logs.push(m),
verbose: (m) => logs.push(m),
success: (m) => logs.push(m),
warn: (m) => logs.push(m),
error: (m) => logs.push(m),
dim: (m) => logs.push(m),
};
const { filterInputs } = createTraceFilters(logger);
const msg = { type: 'ai', content: 'hello' };
const input: KVMap = {
cachedTemplates: [
{
templateId: 't1',
name: 'Template',
// Extra properties that should be filtered out
workflow: { nodes: [], connections: {} },
description: 'A long description that should be removed',
},
],
messages: [msg],
};
const filtered = filterInputs({ ...input });
expect(Array.isArray(filtered.messages)).toBe(true);
expect(filtered.messages).toEqual([msg]);
// Verify that cachedTemplates was summarized - only templateId and name are preserved
expect(filtered.cachedTemplates).toEqual([{ templateId: 't1', name: 'Template' }]);
expect(logs.join('\n')).toContain('LangSmith trace filtering: ACTIVE');
});
});
@@ -0,0 +1,709 @@
/**
* Tests for webhook utilities.
*/
import { jsonParse } from 'n8n-workflow';
import {
validateWebhookUrl,
sendWebhookNotification,
generateWebhookSignature,
verifyWebhookSignature,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
type WebhookPayload,
} from '../cli/webhook';
import type { RunSummary } from '../harness/harness-types';
const mockFetch = jest.fn();
global.fetch = mockFetch;
jest.mock('node:dns/promises', () => ({
resolve: jest.fn().mockResolvedValue(['93.184.216.34']),
resolve6: jest.fn().mockRejectedValue(new Error('ENODATA')),
}));
/** Helper to create a mock logger */
function createMockLogger() {
return {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
success: jest.fn(),
dim: jest.fn(),
isVerbose: false,
};
}
/** Helper to create a mock run summary */
function createMockSummary(overrides: Partial<RunSummary> = {}): RunSummary {
return {
totalExamples: 10,
passed: 8,
failed: 2,
errors: 0,
averageScore: 0.85,
totalDurationMs: 5000,
evaluatorAverages: {
'llm-judge': 0.85,
programmatic: 0.9,
},
langsmith: {
experimentName: 'test-experiment-2024',
experimentId: 'exp-uuid-123',
datasetId: 'dataset-uuid-456',
},
...overrides,
};
}
describe('webhook utilities', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('generateWebhookSignature()', () => {
it('generates consistent signatures for same payload and secret', () => {
const payload = '{"test": "data"}';
const secret = 'test-secret-key-1234';
const sig1 = generateWebhookSignature(payload, secret);
const sig2 = generateWebhookSignature(payload, secret);
expect(sig1).toBe(sig2);
expect(sig1).toMatch(/^sha256=[a-f0-9]{64}$/);
});
it('generates different signatures for different payloads', () => {
const secret = 'test-secret-key-1234';
const sig1 = generateWebhookSignature('payload1', secret);
const sig2 = generateWebhookSignature('payload2', secret);
expect(sig1).not.toBe(sig2);
});
it('generates different signatures for different secrets', () => {
const payload = '{"test": "data"}';
const sig1 = generateWebhookSignature(payload, 'secret1');
const sig2 = generateWebhookSignature(payload, 'secret2');
expect(sig1).not.toBe(sig2);
});
it('generates known signature for test vector', () => {
// This provides a reference for receiver implementations
const payload = '{"suite":"llm-judge","summary":{"totalExamples":10}}';
const secret = 'test-webhook-secret-12345678';
const signature = generateWebhookSignature(payload, secret);
// Verify format
expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/);
// This is the actual expected signature - useful for testing receiver implementations
expect(signature).toBe(
'sha256=0905f894294181ac73c6b2d61538c23dfbc5b023f4c2ab83513b1078a7fabe3c',
);
});
});
describe('verifyWebhookSignature()', () => {
it('returns true for valid signature', () => {
const payload = '{"test": "data"}';
const secret = 'test-secret-key-1234';
const signature = generateWebhookSignature(payload, secret);
expect(verifyWebhookSignature(payload, signature, secret)).toBe(true);
});
it('returns false for invalid signature', () => {
const payload = '{"test": "data"}';
const secret = 'test-secret-key-1234';
expect(verifyWebhookSignature(payload, 'sha256=invalid', secret)).toBe(false);
});
it('returns false for wrong secret', () => {
const payload = '{"test": "data"}';
const signature = generateWebhookSignature(payload, 'correct-secret');
expect(verifyWebhookSignature(payload, signature, 'wrong-secret')).toBe(false);
});
it('returns false for tampered payload', () => {
const secret = 'test-secret-key-1234';
const signature = generateWebhookSignature('original payload', secret);
expect(verifyWebhookSignature('tampered payload', signature, secret)).toBe(false);
});
it('returns false for signature with wrong length', () => {
const payload = '{"test": "data"}';
const secret = 'test-secret-key-1234';
expect(verifyWebhookSignature(payload, 'sha256=tooshort', secret)).toBe(false);
});
});
describe('validateWebhookUrl()', () => {
describe('protocol validation', () => {
it('accepts valid HTTPS URLs', () => {
expect(() => validateWebhookUrl('https://example.com/webhook')).not.toThrow();
expect(() => validateWebhookUrl('https://api.example.com/v1/hook')).not.toThrow();
expect(() =>
validateWebhookUrl('https://hooks.slack.com/services/T00/B00/xxx'),
).not.toThrow();
});
it('rejects HTTP URLs', () => {
expect(() => validateWebhookUrl('http://example.com/webhook')).toThrow(
'Webhook URL must use HTTPS',
);
});
it('rejects other protocols', () => {
expect(() => validateWebhookUrl('ftp://example.com/webhook')).toThrow(
'Webhook URL must use HTTPS',
);
expect(() => validateWebhookUrl('file:///etc/passwd')).toThrow(
'Webhook URL must use HTTPS',
);
});
});
describe('localhost blocking', () => {
it('rejects localhost', () => {
expect(() => validateWebhookUrl('https://localhost/webhook')).toThrow(
'Webhook URL cannot target localhost',
);
expect(() => validateWebhookUrl('https://localhost:3000/webhook')).toThrow(
'Webhook URL cannot target localhost',
);
});
it('rejects 127.0.0.1', () => {
expect(() => validateWebhookUrl('https://127.0.0.1/webhook')).toThrow(
'Webhook URL cannot target localhost',
);
expect(() => validateWebhookUrl('https://127.0.0.1:8080/webhook')).toThrow(
'Webhook URL cannot target localhost',
);
});
it('rejects IPv6 localhost (::1)', () => {
expect(() => validateWebhookUrl('https://[::1]/webhook')).toThrow(
'Webhook URL cannot target localhost',
);
});
});
describe('private IP blocking (SSRF prevention)', () => {
it('rejects 10.x.x.x addresses', () => {
expect(() => validateWebhookUrl('https://10.0.0.1/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
expect(() => validateWebhookUrl('https://10.255.255.255/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
expect(() => validateWebhookUrl('https://10.1.2.3:8080/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
});
it('rejects 172.16-31.x.x addresses', () => {
expect(() => validateWebhookUrl('https://172.16.0.1/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
expect(() => validateWebhookUrl('https://172.31.255.255/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
expect(() => validateWebhookUrl('https://172.20.10.5/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
});
it('allows 172.x.x.x addresses outside private range', () => {
expect(() => validateWebhookUrl('https://172.15.0.1/webhook')).not.toThrow();
expect(() => validateWebhookUrl('https://172.32.0.1/webhook')).not.toThrow();
});
it('rejects 192.168.x.x addresses', () => {
expect(() => validateWebhookUrl('https://192.168.0.1/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
expect(() => validateWebhookUrl('https://192.168.1.100/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
expect(() => validateWebhookUrl('https://192.168.255.255/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
});
it('rejects 169.254.x.x link-local addresses', () => {
expect(() => validateWebhookUrl('https://169.254.0.1/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
expect(() => validateWebhookUrl('https://169.254.169.254/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
});
it('rejects 0.0.0.0', () => {
expect(() => validateWebhookUrl('https://0.0.0.0/webhook')).toThrow(
'Webhook URL cannot target private/internal IP addresses',
);
});
});
describe('internal hostname blocking', () => {
it('rejects "internal" hostname', () => {
expect(() => validateWebhookUrl('https://internal/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
expect(() => validateWebhookUrl('https://api.internal/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
});
it('rejects "intranet" hostname', () => {
expect(() => validateWebhookUrl('https://intranet/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
expect(() => validateWebhookUrl('https://app.intranet/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
});
it('rejects "corp" hostname', () => {
expect(() => validateWebhookUrl('https://corp/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
expect(() => validateWebhookUrl('https://api.corp/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
});
it('rejects "private" hostname', () => {
expect(() => validateWebhookUrl('https://private/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
expect(() => validateWebhookUrl('https://services.private/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
});
it('rejects "local" hostname', () => {
expect(() => validateWebhookUrl('https://local/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
expect(() => validateWebhookUrl('https://api.local/webhook')).toThrow(
'Webhook URL cannot target internal hostname',
);
});
it('allows hostnames containing blocked words but not ending with them', () => {
expect(() => validateWebhookUrl('https://internal-api.example.com/webhook')).not.toThrow();
expect(() =>
validateWebhookUrl('https://my-intranet-app.example.com/webhook'),
).not.toThrow();
});
});
describe('valid URLs', () => {
it('accepts various valid public URLs', () => {
expect(() => validateWebhookUrl('https://hooks.slack.com/services/xxx')).not.toThrow();
expect(() => validateWebhookUrl('https://api.github.com/webhooks')).not.toThrow();
expect(() => validateWebhookUrl('https://webhook.site/unique-id')).not.toThrow();
expect(() => validateWebhookUrl('https://n8n.io/webhook/abc123')).not.toThrow();
});
it('accepts URLs with paths, query params, and ports', () => {
expect(() =>
validateWebhookUrl('https://example.com:8443/api/v1/webhook?token=abc'),
).not.toThrow();
expect(() =>
validateWebhookUrl('https://api.example.com/webhooks/eval/notify'),
).not.toThrow();
});
});
describe('invalid URL format', () => {
it('throws on invalid URL strings', () => {
expect(() => validateWebhookUrl('not-a-url')).toThrow();
expect(() => validateWebhookUrl('')).toThrow();
expect(() => validateWebhookUrl('://missing-protocol.com')).toThrow();
});
});
});
describe('sendWebhookNotification()', () => {
it('sends POST request with correct payload', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
const summary = createMockSummary();
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary,
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: { source: 'ci', trigger: 'push' },
logger,
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith('https://example.com/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: expect.any(String),
});
// Verify payload structure
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
const payload = jsonParse<WebhookPayload>(callArgs[1].body as string);
expect(payload).toEqual({
suite: 'llm-judge',
summary: {
totalExamples: 10,
passed: 8,
failed: 2,
errors: 0,
averageScore: 0.85,
},
evaluatorAverages: {
'llm-judge': 0.85,
programmatic: 0.9,
},
totalDurationMs: 5000,
metadata: { source: 'ci', trigger: 'push' },
langsmith: {
experimentName: 'test-experiment-2024',
experimentId: 'exp-uuid-123',
datasetId: 'dataset-uuid-456',
datasetName: 'test-dataset',
},
});
});
it('logs info messages on success', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
expect(logger.info).toHaveBeenCalledWith(
'Sending results to webhook: https://example.com/***',
);
expect(logger.info).toHaveBeenCalledWith(
'Webhook notification sent successfully (status: 200)',
);
});
it('throws error when webhook URL validation fails', async () => {
const logger = createMockLogger();
await expect(
sendWebhookNotification({
webhookUrl: 'http://example.com/webhook', // HTTP not allowed
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
}),
).rejects.toThrow('Webhook URL must use HTTPS');
expect(mockFetch).not.toHaveBeenCalled();
});
it('throws error when fetch returns non-ok response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
const logger = createMockLogger();
await expect(
sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
}),
).rejects.toThrow('Webhook request failed: 500 Internal Server Error');
});
it('throws error when fetch returns 4xx response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
});
const logger = createMockLogger();
await expect(
sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
}),
).rejects.toThrow('Webhook request failed: 401 Unauthorized');
});
it('throws error when fetch fails with network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const logger = createMockLogger();
await expect(
sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
}),
).rejects.toThrow('Network error');
});
it('handles summary without evaluatorAverages', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
const summary = createMockSummary({ evaluatorAverages: undefined });
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary,
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
const payload = jsonParse<WebhookPayload>(callArgs[1].body as string);
expect(payload.evaluatorAverages).toBeUndefined();
});
it('handles summary without langsmith data (local mode)', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
const summary = createMockSummary({ langsmith: undefined });
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary,
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
const payload = jsonParse<WebhookPayload>(callArgs[1].body as string);
expect(payload.langsmith).toBeUndefined();
});
it('validates URL before making fetch request (SSRF prevention)', async () => {
const logger = createMockLogger();
await expect(
sendWebhookNotification({
webhookUrl: 'https://192.168.1.1/webhook',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
}),
).rejects.toThrow('Webhook URL cannot target private/internal IP addresses');
expect(mockFetch).not.toHaveBeenCalled();
});
describe('HMAC signature', () => {
it('includes signature headers when webhookSecret is provided', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
webhookSecret: 'test-secret-key-1234',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = callArgs[1].headers as Record<string, string>;
// Verify signature header is present and valid format
expect(headers[WEBHOOK_SIGNATURE_HEADER]).toMatch(/^sha256=[a-f0-9]{64}$/);
// Verify timestamp header is present and valid
expect(headers[WEBHOOK_TIMESTAMP_HEADER]).toMatch(/^\d+$/);
const timestamp = parseInt(headers[WEBHOOK_TIMESTAMP_HEADER], 10);
expect(timestamp).toBeGreaterThan(Date.now() - 60000); // Within last minute
expect(timestamp).toBeLessThanOrEqual(Date.now());
});
it('signature can be verified by receiver', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
const secret = 'test-secret-key-1234';
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
webhookSecret: secret,
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = callArgs[1].headers as Record<string, string>;
const body = callArgs[1].body as string;
const signature = headers[WEBHOOK_SIGNATURE_HEADER];
const timestamp = headers[WEBHOOK_TIMESTAMP_HEADER];
// Verify that the receiver can validate the signature
// Signature is computed as: timestamp.body
const signaturePayload = `${timestamp}.${body}`;
expect(verifyWebhookSignature(signaturePayload, signature, secret)).toBe(true);
});
it('does not include signature headers when webhookSecret is not provided', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = callArgs[1].headers as Record<string, string>;
expect(headers[WEBHOOK_SIGNATURE_HEADER]).toBeUndefined();
expect(headers[WEBHOOK_TIMESTAMP_HEADER]).toBeUndefined();
});
it('logs warning when webhook secret is not provided', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('No webhook secret provided'),
);
});
it('logs info when request is signed', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
});
const logger = createMockLogger();
await sendWebhookNotification({
webhookUrl: 'https://example.com/webhook',
webhookSecret: 'test-secret-key-1234',
summary: createMockSummary(),
dataset: 'test-dataset',
suite: 'llm-judge',
metadata: {},
logger,
});
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('signed with HMAC-SHA256'),
);
});
});
});
});