elasticdash-test 0.1.18 → 0.1.19-alpha-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +167 -0
  2. package/dist/ci/api-client.d.ts +23 -0
  3. package/dist/ci/api-client.d.ts.map +1 -0
  4. package/dist/ci/api-client.js +64 -0
  5. package/dist/ci/api-client.js.map +1 -0
  6. package/dist/ci/executor.d.ts +13 -0
  7. package/dist/ci/executor.d.ts.map +1 -0
  8. package/dist/ci/executor.js +539 -0
  9. package/dist/ci/executor.js.map +1 -0
  10. package/dist/ci/git-info.d.ts +13 -0
  11. package/dist/ci/git-info.d.ts.map +1 -0
  12. package/dist/ci/git-info.js +52 -0
  13. package/dist/ci/git-info.js.map +1 -0
  14. package/dist/ci/index.d.ts +6 -0
  15. package/dist/ci/index.d.ts.map +1 -0
  16. package/dist/ci/index.js +4 -0
  17. package/dist/ci/index.js.map +1 -0
  18. package/dist/ci/runner.d.ts +3 -0
  19. package/dist/ci/runner.d.ts.map +1 -0
  20. package/dist/ci/runner.js +178 -0
  21. package/dist/ci/runner.js.map +1 -0
  22. package/dist/ci/types.d.ts +108 -0
  23. package/dist/ci/types.d.ts.map +1 -0
  24. package/dist/ci/types.js +3 -0
  25. package/dist/ci/types.js.map +1 -0
  26. package/dist/cli.js +40 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/dashboard-server.d.ts.map +1 -1
  29. package/dist/dashboard-server.js +37 -3
  30. package/dist/dashboard-server.js.map +1 -1
  31. package/dist/index.cjs +951 -123
  32. package/dist/index.d.ts +5 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/portal-server.d.ts.map +1 -1
  37. package/dist/portal-server.js +15 -1
  38. package/dist/portal-server.js.map +1 -1
  39. package/dist/telemetry-batcher.d.ts.map +1 -1
  40. package/dist/telemetry-batcher.js +5 -1
  41. package/dist/telemetry-batcher.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/ci/api-client.ts +87 -0
  44. package/src/ci/executor.ts +668 -0
  45. package/src/ci/git-info.ts +66 -0
  46. package/src/ci/index.ts +5 -0
  47. package/src/ci/runner.ts +198 -0
  48. package/src/ci/types.ts +115 -0
  49. package/src/cli.ts +55 -0
  50. package/src/dashboard-server.ts +37 -3
  51. package/src/index.ts +7 -0
  52. package/src/portal-server.ts +15 -1
  53. package/src/telemetry-batcher.ts +5 -1
@@ -0,0 +1,668 @@
1
+ import { executePortalTask, checkToolAvailability, checkAIAvailability } from '../portal-executor.js'
2
+ import { scanTools } from '../execution/tool-runner.js'
3
+ import { callProviderLLM } from '../matchers/index.js'
4
+ import type { ToolInfo } from '../execution/tool-runner.js'
5
+ import type {
6
+ APITestGroupTest,
7
+ APIExpectation,
8
+ CISingleRunResult,
9
+ CIExpectationResult,
10
+ } from './types.js'
11
+
12
+ // ─── Test Executor ───────────────────────────────────────────
13
+ // Executes a single TestGroupTest: runs it N times, evaluates expectations,
14
+ // determines pass/fail based on pass_threshold.
15
+
16
+ interface ExecutionResult {
17
+ passed: boolean
18
+ singleRuns: CISingleRunResult[]
19
+ expectationResults: CIExpectationResult[]
20
+ durationMs: number
21
+ }
22
+
23
+ /**
24
+ * Execute a test (single-step or full-flow) according to its configuration.
25
+ */
26
+ export async function executeTest(
27
+ test: APITestGroupTest,
28
+ cwd: string,
29
+ ): Promise<ExecutionResult> {
30
+ const tools = scanTools(cwd)
31
+ const runCount = test.run_count || 1
32
+ const timeoutMs = test.timeout_ms || 30000
33
+ const startTime = Date.now()
34
+
35
+ const singleRuns: CISingleRunResult[] = []
36
+
37
+ for (let i = 0; i < runCount; i++) {
38
+ const runStart = Date.now()
39
+ let result: CISingleRunResult
40
+
41
+ try {
42
+ const runPromise = executeSingleRun(test, cwd, tools, i)
43
+ const timeoutPromise = new Promise<never>((_, reject) =>
44
+ setTimeout(() => reject(new Error(`Test timed out after ${timeoutMs}ms`)), timeoutMs)
45
+ )
46
+ result = await Promise.race([runPromise, timeoutPromise])
47
+ } catch (err) {
48
+ result = {
49
+ runIndex: i + 1,
50
+ passed: false,
51
+ durationMs: Date.now() - runStart,
52
+ inputTokens: 0,
53
+ outputTokens: 0,
54
+ totalTokens: 0,
55
+ output: null,
56
+ trace: null,
57
+ error: err instanceof Error ? err.message : String(err),
58
+ }
59
+ }
60
+
61
+ singleRuns.push(result)
62
+ }
63
+
64
+ // Evaluate expectations against the single runs
65
+ const expectationResults = await evaluateExpectations(test.expectations, singleRuns)
66
+
67
+ // Determine overall pass/fail
68
+ const passed = determinePassFail(test.pass_threshold, singleRuns, expectationResults)
69
+
70
+ return {
71
+ passed,
72
+ singleRuns,
73
+ expectationResults,
74
+ durationMs: Date.now() - startTime,
75
+ }
76
+ }
77
+
78
+ // ─── Single Run Execution ────────────────────────────────────
79
+
80
+ async function executeSingleRun(
81
+ test: APITestGroupTest,
82
+ cwd: string,
83
+ tools: ToolInfo[],
84
+ runIndex: number,
85
+ ): Promise<CISingleRunResult> {
86
+ const start = Date.now()
87
+
88
+ if (test.test_type === 'single-step') {
89
+ return executeSingleStep(test, cwd, tools, runIndex, start)
90
+ }
91
+
92
+ return executeFullFlow(test, cwd, tools, runIndex, start)
93
+ }
94
+
95
+ async function executeSingleStep(
96
+ test: APITestGroupTest,
97
+ cwd: string,
98
+ tools: ToolInfo[],
99
+ runIndex: number,
100
+ start: number,
101
+ ): Promise<CISingleRunResult> {
102
+ const stepType = test.target_step_type // 'ai' or 'tool'
103
+ const stepName = test.target_step_name
104
+
105
+ if (!stepType || !stepName) {
106
+ return {
107
+ runIndex: runIndex + 1,
108
+ passed: false,
109
+ durationMs: Date.now() - start,
110
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
111
+ output: null, trace: null,
112
+ error: 'Single-step test requires target_step_type and target_step_name.',
113
+ }
114
+ }
115
+
116
+ // Pre-validate availability
117
+ const availability = stepType === 'ai'
118
+ ? checkAIAvailability(undefined, stepName)
119
+ : checkToolAvailability(stepName, cwd, tools)
120
+
121
+ if (!availability.available) {
122
+ return {
123
+ runIndex: runIndex + 1,
124
+ passed: false,
125
+ durationMs: Date.now() - start,
126
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
127
+ output: null, trace: null,
128
+ error: `Step unavailable: ${availability.reason}`,
129
+ }
130
+ }
131
+
132
+ // Execute via portal executor (reuses existing infrastructure)
133
+ const result = await executePortalTask(
134
+ {
135
+ taskId: `ci-${test.id}-run-${runIndex}`,
136
+ type: stepType === 'ai' ? 'ai' : 'tool',
137
+ name: stepName,
138
+ input: test.mock_input,
139
+ frozenEvents: test.frozen_events as any[],
140
+ },
141
+ cwd,
142
+ tools,
143
+ )
144
+
145
+ return {
146
+ runIndex: runIndex + 1,
147
+ passed: result.ok,
148
+ durationMs: result.durationMs,
149
+ inputTokens: result.usage?.inputTokens ?? 0,
150
+ outputTokens: result.usage?.outputTokens ?? 0,
151
+ totalTokens: result.usage?.totalTokens ?? 0,
152
+ output: result.output,
153
+ trace: null,
154
+ error: result.error,
155
+ }
156
+ }
157
+
158
+ async function executeFullFlow(
159
+ test: APITestGroupTest,
160
+ cwd: string,
161
+ tools: ToolInfo[],
162
+ runIndex: number,
163
+ start: number,
164
+ ): Promise<CISingleRunResult> {
165
+ // Full-flow tests require running the entire workflow.
166
+ // We leverage the existing workflow runner infrastructure.
167
+ // The workflow is loaded from ed_workflows.ts and executed with workflow_input.
168
+
169
+ try {
170
+ const { runWorkflow } = await import('../workflow-runner.js')
171
+ const { resolveRuntimeModule } = await import('../execution/tool-runner.js')
172
+ const { pathToFileURL } = await import('node:url')
173
+
174
+ const workflowModulePath = resolveRuntimeModule(cwd, 'ed_workflows')
175
+ if (!workflowModulePath) {
176
+ return {
177
+ runIndex: runIndex + 1,
178
+ passed: false,
179
+ durationMs: Date.now() - start,
180
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
181
+ output: null, trace: null,
182
+ error: 'Cannot find ed_workflows.ts/js in workspace root.',
183
+ }
184
+ }
185
+
186
+ // Import the workflow module dynamically
187
+ const workflowModule = await import(pathToFileURL(workflowModulePath).href)
188
+
189
+ // Find a suitable workflow function to execute
190
+ // Convention: use the first exported async function, or match by test group's workflow name
191
+ const workflowFns = Object.entries(workflowModule).filter(
192
+ ([, val]) => typeof val === 'function'
193
+ ) as [string, (...args: unknown[]) => Promise<unknown>][]
194
+
195
+ if (workflowFns.length === 0) {
196
+ return {
197
+ runIndex: runIndex + 1,
198
+ passed: false,
199
+ durationMs: Date.now() - start,
200
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
201
+ output: null, trace: null,
202
+ error: 'No workflow functions found in ed_workflows.',
203
+ }
204
+ }
205
+
206
+ // Prefer matching by name if the test has a target_step_name
207
+ const targetFn = test.target_step_name
208
+ ? workflowFns.find(([name]) => name === test.target_step_name)?.[1]
209
+ : workflowFns[0][1]
210
+
211
+ const fn = targetFn ?? workflowFns[0][1]
212
+
213
+ const frozenEvents = Array.isArray(test.frozen_events) ? test.frozen_events : []
214
+
215
+ const { result, trace } = await runWorkflow(
216
+ () => {
217
+ const input = test.workflow_input
218
+ return fn(input) as Promise<unknown>
219
+ },
220
+ {
221
+ replayMode: frozenEvents.length > 0,
222
+ history: frozenEvents as any[],
223
+ interceptHttp: true,
224
+ interceptSideEffects: true,
225
+ },
226
+ )
227
+
228
+ // Extract usage from trace events
229
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0
230
+ if (trace?.events) {
231
+ for (const evt of trace.events) {
232
+ if (evt.type === 'ai' && evt.usage) {
233
+ inputTokens += (evt.usage as any).inputTokens ?? (evt.usage as any).input ?? 0
234
+ outputTokens += (evt.usage as any).outputTokens ?? (evt.usage as any).output ?? 0
235
+ totalTokens += (evt.usage as any).totalTokens ?? (evt.usage as any).total ?? 0
236
+ }
237
+ }
238
+ }
239
+
240
+ return {
241
+ runIndex: runIndex + 1,
242
+ passed: true,
243
+ durationMs: Date.now() - start,
244
+ inputTokens, outputTokens, totalTokens,
245
+ output: result,
246
+ trace: trace ?? null,
247
+ }
248
+ } catch (err) {
249
+ return {
250
+ runIndex: runIndex + 1,
251
+ passed: false,
252
+ durationMs: Date.now() - start,
253
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
254
+ output: null, trace: null,
255
+ error: err instanceof Error ? err.message : String(err),
256
+ }
257
+ }
258
+ }
259
+
260
+ // ─── Expectation Evaluation ──────────────────────────────────
261
+
262
+ async function evaluateExpectations(
263
+ expectations: APIExpectation[],
264
+ singleRuns: CISingleRunResult[],
265
+ ): Promise<CIExpectationResult[]> {
266
+ const results: CIExpectationResult[] = []
267
+
268
+ for (const exp of expectations) {
269
+ const result = await evaluateExpectation(exp, singleRuns)
270
+ results.push(result)
271
+ }
272
+
273
+ return results
274
+ }
275
+
276
+ async function evaluateExpectation(
277
+ exp: APIExpectation,
278
+ singleRuns: CISingleRunResult[],
279
+ ): Promise<CIExpectationResult> {
280
+ switch (exp.type) {
281
+ case 'token-budget':
282
+ return evaluateTokenBudget(exp, singleRuns)
283
+ case 'latency-budget':
284
+ return evaluateLatencyBudget(exp, singleRuns)
285
+ case 'output-contains':
286
+ return evaluateOutputContains(exp, singleRuns)
287
+ case 'output-schema':
288
+ return evaluateOutputSchema(exp, singleRuns)
289
+ case 'tool-called':
290
+ return evaluateToolCalled(exp, singleRuns)
291
+ case 'determinism':
292
+ return evaluateDeterminism(exp, singleRuns)
293
+ case 'llm-judge':
294
+ return evaluateLLMJudge(exp, singleRuns)
295
+ default:
296
+ return {
297
+ expectationId: exp.id,
298
+ type: exp.type,
299
+ passed: false,
300
+ detail: `Unknown expectation type: ${exp.type}`,
301
+ }
302
+ }
303
+ }
304
+
305
+ function evaluateTokenBudget(exp: APIExpectation, runs: CISingleRunResult[]): CIExpectationResult {
306
+ const perRun: Record<number, { passed: boolean; detail?: string }> = {}
307
+ let allPassed = true
308
+
309
+ for (const run of runs) {
310
+ let passed = true
311
+ const details: string[] = []
312
+
313
+ if (exp.max_tokens_per_run != null && run.totalTokens > exp.max_tokens_per_run) {
314
+ passed = false
315
+ details.push(`total ${run.totalTokens} > max ${exp.max_tokens_per_run}`)
316
+ }
317
+ perRun[run.runIndex] = { passed, detail: details.join('; ') || undefined }
318
+ if (!passed) allPassed = false
319
+ }
320
+
321
+ if (exp.max_total_tokens != null) {
322
+ const total = runs.reduce((sum, r) => sum + r.totalTokens, 0)
323
+ if (total > exp.max_total_tokens) {
324
+ allPassed = false
325
+ }
326
+ }
327
+
328
+ return {
329
+ expectationId: exp.id,
330
+ type: 'token-budget',
331
+ passed: allPassed,
332
+ detail: allPassed ? undefined : 'Token budget exceeded.',
333
+ perRun,
334
+ }
335
+ }
336
+
337
+ function evaluateLatencyBudget(exp: APIExpectation, runs: CISingleRunResult[]): CIExpectationResult {
338
+ const perRun: Record<number, { passed: boolean; detail?: string }> = {}
339
+ let allPassed = true
340
+
341
+ for (const run of runs) {
342
+ let passed = true
343
+ if (exp.max_duration_ms != null && run.durationMs > exp.max_duration_ms) {
344
+ passed = false
345
+ }
346
+ perRun[run.runIndex] = { passed, detail: passed ? undefined : `${run.durationMs}ms > ${exp.max_duration_ms}ms` }
347
+ if (!passed) allPassed = false
348
+ }
349
+
350
+ if (exp.max_total_duration_ms != null) {
351
+ const total = runs.reduce((sum, r) => sum + r.durationMs, 0)
352
+ if (total > exp.max_total_duration_ms) {
353
+ allPassed = false
354
+ }
355
+ }
356
+
357
+ return {
358
+ expectationId: exp.id,
359
+ type: 'latency-budget',
360
+ passed: allPassed,
361
+ detail: allPassed ? undefined : 'Latency budget exceeded.',
362
+ perRun,
363
+ }
364
+ }
365
+
366
+ function evaluateOutputContains(exp: APIExpectation, runs: CISingleRunResult[]): CIExpectationResult {
367
+ const perRun: Record<number, { passed: boolean; detail?: string }> = {}
368
+ let allPassed = true
369
+
370
+ for (const run of runs) {
371
+ const outputStr = typeof run.output === 'string'
372
+ ? run.output
373
+ : JSON.stringify(run.output ?? '')
374
+
375
+ const haystack = exp.case_insensitive ? outputStr.toLowerCase() : outputStr
376
+ let passed = true
377
+
378
+ if (exp.contains_text) {
379
+ const needle = exp.case_insensitive ? exp.contains_text.toLowerCase() : exp.contains_text
380
+ if (!haystack.includes(needle)) passed = false
381
+ }
382
+ if (exp.not_contains_text) {
383
+ const needle = exp.case_insensitive ? exp.not_contains_text.toLowerCase() : exp.not_contains_text
384
+ if (haystack.includes(needle)) passed = false
385
+ }
386
+
387
+ perRun[run.runIndex] = { passed, detail: passed ? undefined : 'Output text check failed.' }
388
+ if (!passed) allPassed = false
389
+ }
390
+
391
+ return {
392
+ expectationId: exp.id,
393
+ type: 'output-contains',
394
+ passed: allPassed,
395
+ detail: allPassed ? undefined : 'Output contains check failed.',
396
+ perRun,
397
+ }
398
+ }
399
+
400
+ function evaluateOutputSchema(exp: APIExpectation, runs: CISingleRunResult[]): CIExpectationResult {
401
+ // Basic JSON schema validation: check that output is valid JSON matching the schema's type/required fields
402
+ const perRun: Record<number, { passed: boolean; detail?: string }> = {}
403
+ let allPassed = true
404
+
405
+ const schema = exp.json_schema as Record<string, unknown> | null
406
+
407
+ for (const run of runs) {
408
+ if (!schema) {
409
+ perRun[run.runIndex] = { passed: true }
410
+ continue
411
+ }
412
+
413
+ let output = run.output
414
+ if (typeof output === 'string') {
415
+ try { output = JSON.parse(output) } catch {
416
+ perRun[run.runIndex] = { passed: false, detail: 'Output is not valid JSON.' }
417
+ allPassed = false
418
+ continue
419
+ }
420
+ }
421
+
422
+ // Check type
423
+ const schemaType = schema.type as string | undefined
424
+ let passed = true
425
+
426
+ if (schemaType === 'object' && (typeof output !== 'object' || output === null || Array.isArray(output))) {
427
+ passed = false
428
+ } else if (schemaType === 'array' && !Array.isArray(output)) {
429
+ passed = false
430
+ } else if (schemaType === 'string' && typeof output !== 'string') {
431
+ passed = false
432
+ }
433
+
434
+ // Check required fields
435
+ if (passed && schemaType === 'object' && Array.isArray(schema.required)) {
436
+ for (const key of schema.required as string[]) {
437
+ if (!(key in (output as Record<string, unknown>))) {
438
+ passed = false
439
+ break
440
+ }
441
+ }
442
+ }
443
+
444
+ perRun[run.runIndex] = { passed, detail: passed ? undefined : 'Output does not match schema.' }
445
+ if (!passed) allPassed = false
446
+ }
447
+
448
+ return {
449
+ expectationId: exp.id,
450
+ type: 'output-schema',
451
+ passed: allPassed,
452
+ detail: allPassed ? undefined : 'Output schema check failed.',
453
+ perRun,
454
+ }
455
+ }
456
+
457
+ function evaluateToolCalled(exp: APIExpectation, runs: CISingleRunResult[]): CIExpectationResult {
458
+ const perRun: Record<number, { passed: boolean; detail?: string }> = {}
459
+ let allPassed = true
460
+
461
+ for (const run of runs) {
462
+ // Extract tool calls from trace
463
+ const trace = run.trace as { events?: Array<{ type: string; name: string }> } | null
464
+ const toolNames = trace?.events
465
+ ?.filter((e) => e.type === 'tool')
466
+ .map((e) => e.name) ?? []
467
+
468
+ let passed = true
469
+
470
+ // Check required tools
471
+ if (exp.required_tools?.length) {
472
+ for (const required of exp.required_tools) {
473
+ if (!toolNames.includes(required)) {
474
+ passed = false
475
+ break
476
+ }
477
+ }
478
+ }
479
+
480
+ // Check forbidden tools
481
+ if (exp.forbidden_tools?.length) {
482
+ for (const forbidden of exp.forbidden_tools) {
483
+ if (toolNames.includes(forbidden)) {
484
+ passed = false
485
+ break
486
+ }
487
+ }
488
+ }
489
+
490
+ perRun[run.runIndex] = { passed, detail: passed ? undefined : `Tools called: [${toolNames.join(', ')}]` }
491
+ if (!passed) allPassed = false
492
+ }
493
+
494
+ return {
495
+ expectationId: exp.id,
496
+ type: 'tool-called',
497
+ passed: allPassed,
498
+ detail: allPassed ? undefined : 'Tool call check failed.',
499
+ perRun,
500
+ }
501
+ }
502
+
503
+ function evaluateDeterminism(exp: APIExpectation, runs: CISingleRunResult[]): CIExpectationResult {
504
+ if (runs.length < 2) {
505
+ return {
506
+ expectationId: exp.id,
507
+ type: 'determinism',
508
+ passed: true,
509
+ detail: 'Only one run — determinism check skipped.',
510
+ }
511
+ }
512
+
513
+ // Compare outputs pairwise
514
+ const outputs = runs.map((r) =>
515
+ typeof r.output === 'string' ? r.output : JSON.stringify(r.output ?? '')
516
+ )
517
+
518
+ const reference = outputs[0]
519
+ let allSame = true
520
+ const perRun: Record<number, { passed: boolean; detail?: string }> = {}
521
+
522
+ for (let i = 0; i < runs.length; i++) {
523
+ const same = outputs[i] === reference
524
+ perRun[runs[i].runIndex] = { passed: same, detail: same ? undefined : 'Output differs from run 1.' }
525
+ if (!same) allSame = false
526
+ }
527
+
528
+ // If similarity_threshold is set, do a basic character-level similarity check
529
+ if (!allSame && exp.similarity_threshold != null) {
530
+ const threshold = exp.similarity_threshold
531
+ let allAboveThreshold = true
532
+ for (let i = 1; i < outputs.length; i++) {
533
+ const similarity = computeStringSimilarity(reference, outputs[i])
534
+ if (similarity < threshold) {
535
+ allAboveThreshold = false
536
+ perRun[runs[i].runIndex] = {
537
+ passed: false,
538
+ detail: `Similarity ${(similarity * 100).toFixed(1)}% < ${(threshold * 100).toFixed(1)}%`,
539
+ }
540
+ } else {
541
+ perRun[runs[i].runIndex] = { passed: true }
542
+ }
543
+ }
544
+ return {
545
+ expectationId: exp.id,
546
+ type: 'determinism',
547
+ passed: allAboveThreshold,
548
+ detail: allAboveThreshold ? undefined : 'Outputs are not sufficiently similar.',
549
+ perRun,
550
+ }
551
+ }
552
+
553
+ return {
554
+ expectationId: exp.id,
555
+ type: 'determinism',
556
+ passed: allSame,
557
+ detail: allSame ? undefined : 'Outputs are not identical across runs.',
558
+ perRun,
559
+ }
560
+ }
561
+
562
+ function computeStringSimilarity(a: string, b: string): number {
563
+ if (a === b) return 1
564
+ if (!a.length || !b.length) return 0
565
+ // Simple character overlap ratio
566
+ const longer = a.length >= b.length ? a : b
567
+ const shorter = a.length < b.length ? a : b
568
+ let matches = 0
569
+ for (let i = 0; i < shorter.length; i++) {
570
+ if (shorter[i] === longer[i]) matches++
571
+ }
572
+ return matches / longer.length
573
+ }
574
+
575
+ async function evaluateLLMJudge(exp: APIExpectation, runs: CISingleRunResult[]): Promise<CIExpectationResult> {
576
+ if (!exp.judge_prompt) {
577
+ return {
578
+ expectationId: exp.id,
579
+ type: 'llm-judge',
580
+ passed: false,
581
+ detail: 'LLM judge expectation requires judge_prompt.',
582
+ }
583
+ }
584
+
585
+ const perRun: Record<number, { passed: boolean; detail?: string }> = {}
586
+ let allPassed = true
587
+ const threshold = exp.judge_score_threshold ?? 7
588
+
589
+ for (const run of runs) {
590
+ const outputStr = typeof run.output === 'string'
591
+ ? run.output
592
+ : JSON.stringify(run.output ?? '')
593
+
594
+ const evalPrompt = `${exp.judge_prompt}
595
+
596
+ Output to evaluate:
597
+ ${outputStr}
598
+
599
+ Score this output on a scale of 0-10. Respond with only the number.`
600
+
601
+ try {
602
+ const provider = (exp.judge_provider as 'openai' | 'claude' | 'gemini' | 'grok' | 'kimi') || 'openai'
603
+ const result = await callProviderLLM(
604
+ evalPrompt,
605
+ { provider, model: exp.judge_model ?? undefined },
606
+ 'You are an expert test judge. Return only a number between 0 and 10.',
607
+ 16,
608
+ 0,
609
+ )
610
+
611
+ const score = parseFloat(result.content.match(/-?\d+(?:\.\d+)?/)?.[0] ?? '')
612
+ if (isNaN(score)) {
613
+ perRun[run.runIndex] = { passed: false, detail: `Could not parse score from: "${result.content}"` }
614
+ allPassed = false
615
+ } else {
616
+ const passed = score >= threshold
617
+ perRun[run.runIndex] = { passed, detail: `Score: ${score}/${threshold}` }
618
+ if (!passed) allPassed = false
619
+ }
620
+ } catch (err) {
621
+ perRun[run.runIndex] = {
622
+ passed: false,
623
+ detail: `LLM judge error: ${err instanceof Error ? err.message : String(err)}`,
624
+ }
625
+ allPassed = false
626
+ }
627
+ }
628
+
629
+ return {
630
+ expectationId: exp.id,
631
+ type: 'llm-judge',
632
+ passed: allPassed,
633
+ detail: allPassed ? undefined : 'LLM judge check failed.',
634
+ perRun,
635
+ }
636
+ }
637
+
638
+ // ─── Pass/Fail Threshold ─────────────────────────────────────
639
+
640
+ function determinePassFail(
641
+ passThreshold: string,
642
+ singleRuns: CISingleRunResult[],
643
+ expectationResults: CIExpectationResult[],
644
+ ): boolean {
645
+ // Check single run pass/fail
646
+ const runsPassed = singleRuns.filter((r) => r.passed).length
647
+ const totalRuns = singleRuns.length
648
+
649
+ let runsPass: boolean
650
+ switch (passThreshold) {
651
+ case 'all':
652
+ runsPass = runsPassed === totalRuns
653
+ break
654
+ case 'majority':
655
+ runsPass = runsPassed > totalRuns / 2
656
+ break
657
+ case 'any':
658
+ runsPass = runsPassed > 0
659
+ break
660
+ default:
661
+ runsPass = runsPassed === totalRuns
662
+ }
663
+
664
+ // All expectations must pass regardless of threshold
665
+ const expectationsPass = expectationResults.every((e) => e.passed)
666
+
667
+ return runsPass && expectationsPass
668
+ }