Skip to content

Commit 3cd05ce

Browse files
feat: add e2e testcases functional test and sample (#122)
- Update athena-protobufs submodule to include testcases/ - Add functional test for integrator_sample testcases - Add e2e-testcases sample with CLI for running test sets - Support multiple test sets: integrator_sample, benign_model, live_model - Configurable tolerance for floating-point comparisons Co-authored-by: Will Speak <will.speak@kroll.com>
1 parent 2f76672 commit 3cd05ce

11 files changed

Lines changed: 1332 additions & 4 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, it, beforeAll } from 'vitest';
2+
import { ClassifierSdk, ImageFormat, parseAudience } from '../../src';
3+
import fs from 'fs';
4+
import path from 'path';
5+
6+
/**
7+
* Expected outputs schema from testcases
8+
*/
9+
interface ExpectedOutputs {
10+
classification_labels: string[];
11+
images: [string, number[]][]; // [filename, weights[]]
12+
}
13+
14+
/**
15+
* Determine image format from filename extension
16+
*/
17+
function getImageFormat(filename: string): ImageFormat {
18+
const ext = path.extname(filename).toLowerCase();
19+
switch (ext) {
20+
case '.jpg':
21+
case '.jpeg':
22+
return ImageFormat.IMAGE_FORMAT_JPEG;
23+
case '.png':
24+
return ImageFormat.IMAGE_FORMAT_PNG;
25+
case '.gif':
26+
return ImageFormat.IMAGE_FORMAT_GIF;
27+
case '.bmp':
28+
return ImageFormat.IMAGE_FORMAT_BMP;
29+
case '.webp':
30+
return ImageFormat.IMAGE_FORMAT_WEBP;
31+
default:
32+
return ImageFormat.IMAGE_FORMAT_PNG;
33+
}
34+
}
35+
36+
/**
37+
* Default tolerance for floating-point comparisons.
38+
* See athena-protobufs/testcases/README.md for guidance:
39+
* - 1e-4 (0.0001) for exact model match
40+
* - 1e-2 (0.01) for model version drift
41+
* - 0.05 (5%) for behavioral validation
42+
*/
43+
const DEFAULT_TOLERANCE = 0.0001;
44+
45+
describe('E2E Test Cases', () => {
46+
const testcasesDir = path.resolve(__dirname, '../../athena-protobufs/testcases');
47+
const testset = 'integrator_sample';
48+
const tolerance = parseFloat(process.env.E2E_TOLERANCE ?? '') || DEFAULT_TOLERANCE;
49+
50+
let sdk: ClassifierSdk;
51+
let expectedOutputs: ExpectedOutputs;
52+
let imagesDir: string;
53+
54+
beforeAll(() => {
55+
// Initialize SDK with environment configuration
56+
sdk = new ClassifierSdk({
57+
deploymentId: process.env.VITE_ATHENA_DEPLOYMENT_ID ?? '',
58+
affiliate: process.env.VITE_ATHENA_AFFILIATE ?? '',
59+
grpcAddress:
60+
process.env.VITE_ATHENA_GRPC_ADDRESS ??
61+
'trust-messages-global.crispthinking.com:443',
62+
authentication: {
63+
issuerUrl:
64+
process.env.VITE_OAUTH_ISSUER ?? 'https://crispthinking.auth0.com/',
65+
clientId: process.env.VITE_ATHENA_CLIENT_ID ?? '',
66+
clientSecret: process.env.VITE_ATHENA_CLIENT_SECRET ?? '',
67+
audience: parseAudience(process.env.VITE_ATHENA_AUDIENCE),
68+
scope: 'manage:classify',
69+
},
70+
});
71+
72+
// Load expected outputs
73+
const expectedOutputsPath = path.join(
74+
testcasesDir,
75+
testset,
76+
'expected_outputs.json',
77+
);
78+
expectedOutputs = JSON.parse(
79+
fs.readFileSync(expectedOutputsPath, 'utf-8'),
80+
) as ExpectedOutputs;
81+
imagesDir = path.join(testcasesDir, testset, 'images');
82+
});
83+
84+
describe(`${testset} testcases`, () => {
85+
it('should load expected outputs correctly', ({ expect }) => {
86+
expect(expectedOutputs).toBeDefined();
87+
expect(expectedOutputs.classification_labels).toBeInstanceOf(Array);
88+
expect(expectedOutputs.classification_labels.length).toBeGreaterThan(0);
89+
expect(expectedOutputs.images).toBeInstanceOf(Array);
90+
expect(expectedOutputs.images.length).toBeGreaterThan(0);
91+
});
92+
93+
it('should have all test images available', ({ expect }) => {
94+
for (const [filename] of expectedOutputs.images) {
95+
const imagePath = path.join(imagesDir, filename);
96+
expect(
97+
fs.existsSync(imagePath),
98+
`Image not found: ${filename}`,
99+
).toBe(true);
100+
}
101+
});
102+
103+
it('should classify all images and match expected outputs', async ({
104+
expect,
105+
annotate,
106+
}) => {
107+
const labels = expectedOutputs.classification_labels;
108+
const failures: {
109+
filename: string;
110+
differences: { label: string; expected: number; actual: number; diff: number }[];
111+
}[] = [];
112+
113+
annotate(`Running ${expectedOutputs.images.length} test images with tolerance ${tolerance}`);
114+
115+
for (const [filename, expectedWeights] of expectedOutputs.images) {
116+
const imagePath = path.join(imagesDir, filename);
117+
118+
const response = await sdk.classifySingle({
119+
data: fs.createReadStream(imagePath),
120+
format: getImageFormat(filename),
121+
});
122+
123+
expect(response.error, `Classification error for ${filename}`).toBeNull();
124+
125+
// Build actual weights map
126+
const actualWeights = new Map<string, number>();
127+
if (response.classifications) {
128+
for (const classification of response.classifications) {
129+
actualWeights.set(classification.label, classification.weight);
130+
}
131+
}
132+
133+
// Compare each label
134+
const differences: { label: string; expected: number; actual: number; diff: number }[] = [];
135+
for (let i = 0; i < labels.length; i++) {
136+
const label = labels[i];
137+
const expected = expectedWeights[i];
138+
const actual = actualWeights.get(label) ?? 0;
139+
const diff = Math.abs(expected - actual);
140+
141+
if (diff > tolerance) {
142+
differences.push({ label, expected, actual, diff });
143+
}
144+
}
145+
146+
if (differences.length > 0) {
147+
failures.push({ filename, differences });
148+
}
149+
}
150+
151+
// Report all failures at once for better test output
152+
if (failures.length > 0) {
153+
const failureDetails = failures
154+
.map(
155+
(f) =>
156+
`${f.filename}:\n${f.differences
157+
.map(
158+
(d) =>
159+
` - ${d.label}: expected ${d.expected.toFixed(4)}, got ${d.actual.toFixed(4)} (diff: ${d.diff.toFixed(4)})`,
160+
)
161+
.join('\n')}`,
162+
)
163+
.join('\n\n');
164+
165+
annotate(`Failures:\n${failureDetails}`);
166+
}
167+
168+
expect(
169+
failures.length,
170+
`${failures.length} images failed classification comparison`,
171+
).toBe(0);
172+
}, 120000); // 2 minute timeout for all images
173+
});
174+
});

athena-protobufs

Submodule athena-protobufs updated 117 files

samples/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ A TypeScript server for continuous image classification with hash computation. P
3333

3434
- **Use Case**: Long-running classification service with hash computation
3535
- **Features**: Continuous processing, MD5/SHA1 hashes, graceful shutdown
36+
37+
### [E2E Test Cases](./e2e-testcases/)
38+
**End-to-End Test Case Runner**
39+
40+
Runs shared test cases from `athena-protobufs/testcases/` through the classification service and validates results against expected outputs.
41+
42+
- **Use Case**: Integration testing and validation against known test cases
43+
- **Features**: Multiple test sets, tolerance-based comparison, detailed reporting

samples/e2e-testcases/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Athena E2E Test Cases Runner
2+
3+
Runs the shared end-to-end test cases from `athena-protobufs/testcases/` through the Athena classification service and validates results against expected outputs.
4+
5+
## Features
6+
7+
-**Multiple test sets** - Support for integrator_sample, benign_model, and live_model
8+
- 📊 **Tolerance-based comparison** - Configurable tolerance for floating-point comparisons
9+
- 🎯 **Detailed reporting** - Per-image results with label-by-label comparison
10+
- ⚙️ **Environment-based configuration** - Uses shared samples configuration
11+
- 📋 **Summary statistics** - Pass/fail counts and overall status
12+
13+
## Installation
14+
15+
```bash
16+
npm install
17+
```
18+
19+
## Configuration
20+
21+
Create a `.env` file in the samples directory with your Athena credentials as described in [the samples overview](../README.md).
22+
23+
Then source the environment:
24+
```bash
25+
set -a && source ../.env && set +a
26+
```
27+
28+
## Usage
29+
30+
### Basic Usage
31+
32+
```bash
33+
# Run default test set (integrator_sample)
34+
npx tsx index.ts
35+
36+
# Using npm script
37+
npm start
38+
```
39+
40+
### Options
41+
42+
```bash
43+
# Run specific test set
44+
npx tsx index.ts --testset integrator_sample
45+
npx tsx index.ts --testset benign_model
46+
npx tsx index.ts --testset live_model
47+
48+
# Adjust tolerance (default: 0.0001)
49+
npx tsx index.ts --tolerance 0.01
50+
51+
# Verbose output
52+
npx tsx index.ts --verbose
53+
54+
# Show help
55+
npx tsx index.ts --help
56+
```
57+
58+
### Example Output
59+
60+
```
61+
🧪 Athena E2E Test Case Runner
62+
63+
Configuration:
64+
Test Set: integrator_sample
65+
Tolerance: 0.0001
66+
Images: 10
67+
68+
Running tests...
69+
70+
✓ v_YoYo_g08_c04_0186.png - PASS
71+
✓ v_SumoWrestling_g08_c01_0036.png - PASS
72+
✓ v_Diving_g14_c02_0280.png - PASS
73+
✗ v_BrushingTeeth_g05_c05_0219.png - FAIL
74+
Label 'UnknownCSAM-classC': expected 0.1223, got 0.1250 (diff: 0.0027)
75+
76+
Results: 9/10 passed (90%)
77+
```
78+
79+
## Test Sets
80+
81+
| Test Set | Description | Use Case |
82+
|----------|-------------|----------|
83+
| `integrator_sample` | 10 curated images | Quick integration testing |
84+
| `benign_model` | Safe benign images | Development/CI testing |
85+
| `live_model` | Full test set | Production validation |
86+
87+
## Exit Codes
88+
89+
- `0` - All tests passed
90+
- `1` - One or more tests failed
91+
- `2` - Configuration or runtime error

0 commit comments

Comments
 (0)