UNPKG

@arizeai/phoenix-client

Version:
351 lines (327 loc) 9.92 kB
import { createAcceptanceFailureError, evaluateAcceptanceCriteria, } from "./acceptance"; import { flushAnnotations } from "./helpers"; import { initializeSuite, postExperimentRun, resolveRepetitions, runTaskWithTracing, teardownSuite, } from "./phoenix-test-tracking"; import { writeSuiteSummaryArtifact } from "./report-artifacts"; import { currentSuite, popSuite, pushSuite, runStorage, type RunState, type SuiteState, type TestResult, } from "./state"; import { type KVMap, resolveReference, type SuiteConfig, type TestArgs, type TestFn, type TestParams, } from "./types"; /** * The minimum slice of a test runner (vitest or jest) the runner needs in * order to declare suites, tests, and lifecycle hooks. */ export interface RunnerHooks { describe: (name: string, fn: () => void) => void; describeOnly?: (name: string, fn: () => void) => void; describeSkip?: (name: string, fn: () => void) => void; test: ( name: string, fn: (...args: unknown[]) => unknown | Promise<unknown>, timeout?: number ) => void; testOnly?: ( name: string, fn: (...args: unknown[]) => unknown | Promise<unknown>, timeout?: number ) => void; testSkip?: ( name: string, fn: (...args: unknown[]) => unknown | Promise<unknown>, timeout?: number ) => void; beforeAll: (fn: () => unknown | Promise<unknown>) => void; afterAll: (fn: () => unknown | Promise<unknown>) => void; } /** Variants exposed by `test` (and `.skip`/`.only`). */ type TestVariant = "default" | "only" | "skip"; /** Globally accessible registry of all suites we created in this process. */ const allSuites: SuiteState[] = []; /** Drive a `describe(name, fn, config?)` invocation. */ export function declareDescribe( hooks: RunnerHooks, name: string, fn: () => void, config: SuiteConfig = {}, variant: TestVariant = "default" ): void { const describeFn = variant === "only" ? (hooks.describeOnly ?? hooks.describe) : variant === "skip" ? (hooks.describeSkip ?? hooks.describe) : hooks.describe; describeFn(name, () => { const suite: SuiteState = { name, config, registeredExamples: new Map(), exampleIdsByTest: new Map(), trackingDisabled: false, results: [], links: [], }; pushSuite(suite); try { fn(); } finally { popSuite(); } allSuites.push(suite); hooks.beforeAll(async () => { await initializeSuite(suite); }); hooks.afterAll(async () => { let teardownError: unknown; let acceptanceError: Error | undefined; try { await teardownSuite(suite); } catch (err) { teardownError = err; } try { suite.acceptanceResults = evaluateAcceptanceCriteria({ criteria: suite.config.acceptanceCriteria, results: suite.results, }); acceptanceError = createAcceptanceFailureError(suite.acceptanceResults); } finally { writeSuiteSummaryArtifact(suite); } if (teardownError) { throw teardownError; } if (acceptanceError) { throw acceptanceError; } }); }); } /** Drive a `test(name, params, fn)` invocation. */ export function declareTest<Input extends KVMap, Expected extends KVMap>( hooks: RunnerHooks, name: string, params: TestParams<Input, Expected>, fn: TestFn<Input, Expected>, variant: TestVariant = "default", timeout?: number ): void { const suite = currentSuite(); if (!suite) { throw new Error( "Phoenix eval test() must be declared inside a describe() block" ); } // Collapse the `expected` / `reference` / `output` aliases into the single // canonical `expected` slot up front, so every downstream consumer (dataset // upload, evaluator params, test args) reads one field. params = normalizeReferenceOutput(params); // A per-test `dryRun` opts the case out of Phoenix entirely: no dataset // example, no experiment run, no annotations — it runs as an ordinary // local test. Suite-level dryRun (or PHOENIX_TEST_TRACKING=false) is // handled in `initializeSuite`, which never uploads anything anyway. const isDryRun = !!params.dryRun; // Avoid silently overwriting an example registration when the same test name // is declared twice in the same suite. const uniqueName = ensureUniqueName(suite, name); // Skipped cases never run, and registering them would still upload their // example to the tracked dataset via `initializeSuite()` — contradicting the // skip contract and letting unfinished/flaky cases mutate the dataset. Dry-run // cases opt out of Phoenix entirely. Neither should register an example. if (!isDryRun && variant !== "skip") { suite.registeredExamples.set(uniqueName, { testName: uniqueName, params: params as TestParams, }); } const repetitions = isDryRun ? 1 : resolveRepetitions(params.repetitions, suite); suite.maxRepetitions = Math.max(suite.maxRepetitions ?? 1, repetitions); const testFn = variant === "only" ? (hooks.testOnly ?? hooks.test) : variant === "skip" ? (hooks.testSkip ?? hooks.test) : hooks.test; for (let rep = 1; rep <= repetitions; rep++) { const runnerName = repetitions > 1 ? `${uniqueName} [rep ${rep}/${repetitions}]` : uniqueName; testFn( runnerName, async () => { await executeRun({ suite, runnerName, logicalName: uniqueName, repetitionNumber: rep, repetitions, dryRun: isDryRun, params: params as TestParams, fn: fn as TestFn, }); }, timeout ); } } /** Execute a single (possibly-repeated) run of a declared test. */ async function executeRun(opts: { suite: SuiteState; runnerName: string; logicalName: string; repetitionNumber: number; repetitions: number; dryRun: boolean; params: TestParams; fn: TestFn; }): Promise<void> { const { suite, runnerName, logicalName, repetitionNumber, repetitions } = opts; const run: RunState = { suite, testName: runnerName, logicalName, repetitionNumber, dryRun: opts.dryRun, params: opts.params, output: undefined, outputSet: false, annotations: [], startTime: new Date(), runMetadata: {}, }; const start = Date.now(); let status: "passed" | "failed" = "passed"; let thrown: unknown; let testError: string | undefined; await runStorage.run(run, async () => { const taskOutcome = await runTaskWithTracing( suite, runnerName, async () => { const args: TestArgs = { input: opts.params.input, expected: opts.params.expected, metadata: opts.params.metadata, }; const result = await opts.fn(args); // If the user returned a value and didn't call logOutput(), // adopt the return value as the run's output. if ( result !== undefined && !run.outputSet && isPlainObjectOrPrimitive(result) ) { run.output = result; run.outputSet = true; } return result; } ); run.traceId = taskOutcome.traceId; if ("error" in taskOutcome && taskOutcome.error) { status = "failed"; testError = taskOutcome.error.message; if (taskOutcome.isTaskError) { run.error = taskOutcome.error.message; } thrown = taskOutcome.error; } run.endTime = new Date(); // The pass/fail annotation is recorded regardless of tracking mode // so it shows up consistently in the reporter summary. run.annotations.unshift({ name: "pass", score: status === "passed", annotatorKind: "CODE", }); const runId = await postExperimentRun(suite, run); run.runId = runId; await flushAnnotations(runId, run.annotations, suite); }); const result: TestResult = { suiteName: suite.name, testName: runnerName, status, output: run.outputSet ? run.output : undefined, annotations: run.annotations, error: testError, durationMs: Date.now() - start, repetitionNumber: repetitions > 1 ? repetitionNumber : undefined, repetitions: repetitions > 1 ? repetitions : undefined, dryRun: opts.dryRun || undefined, traceId: run.traceId, runId: run.runId, exampleId: suite.exampleIdsByTest.get(logicalName)?.exampleId, }; suite.results.push(result); if (thrown) { throw thrown; } } /** * Return a copy of `params` with the reference output collapsed onto the * canonical `expected` key and the `reference` / `output` aliases dropped. */ function normalizeReferenceOutput<Input extends KVMap, Expected extends KVMap>( params: TestParams<Input, Expected> ): TestParams<Input, Expected> { const expected = resolveReference(params); const { reference: _reference, output: _output, ...rest } = params; return { ...rest, expected }; } function isPlainObjectOrPrimitive(value: unknown): boolean { if (value === null) return true; const type = typeof value; return ( type === "string" || type === "number" || type === "boolean" || type === "object" ); } function ensureUniqueName(suite: SuiteState, name: string): string { if (!suite.registeredExamples.has(name)) { return name; } let i = 2; while (suite.registeredExamples.has(`${name} (${i})`)) { i++; } return `${name} (${i})`; } /** Suite registry exposed for reporters and the public summary helper. */ export function getAllSuites(): readonly SuiteState[] { return allSuites; } /** * Reset the suite registry. Reporters call this at the start of every test * run so module-cached state from previous watch invocations is released. */ export function clearAllSuites(): void { allSuites.length = 0; }