@arizeai/phoenix-client
Version:
A client for the Phoenix API
351 lines (327 loc) • 9.92 kB
text/typescript
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;
}