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
404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|