@arizeai/phoenix-client
Version:
A client for the Phoenix API
225 lines • 8.89 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PHOENIX_TEST_REPORT_DIR_ENV_VAR = void 0;
exports.getSuiteSummaryReportDir = getSuiteSummaryReportDir;
exports.clearSuiteSummaryArtifacts = clearSuiteSummaryArtifacts;
exports.writeSuiteSummaryArtifact = writeSuiteSummaryArtifact;
exports.readSuiteSummaryArtifacts = readSuiteSummaryArtifacts;
/**
* Cross-process bridge for the end-of-run Phoenix summary.
*
* Both Vitest and Jest run test files in separate worker processes, so a
* suite's results live in a different process than the reporter that prints
* the final summary. To bridge that gap, each worker writes a small JSON
* artifact describing its suite to a shared temp directory when the suite
* finishes (see {@link writeSuiteSummaryArtifact}). At the end of the run the
* reporter — back in the main process — reads every artifact written since the
* run began (see {@link readSuiteSummaryArtifacts}) and prints them together.
*
* Everything here is best-effort: a failure to write or read an artifact must
* never fail a user's test, so the file system calls swallow their errors.
*/
const node_crypto_1 = require("node:crypto");
const node_fs_1 = require("node:fs");
const node_os_1 = require("node:os");
const node_path_1 = require("node:path");
/**
* Env var that overrides where suite summary artifacts are written. When unset,
* a stable per-project subdirectory of the OS temp dir is used (see
* {@link getSuiteSummaryReportDir}).
*/
exports.PHOENIX_TEST_REPORT_DIR_ENV_VAR = "PHOENIX_TEST_REPORT_DIR";
const ARTIFACT_VERSION = 1;
const ARTIFACT_FILE_PREFIX = "suite-";
let artifactCounter = 0;
/**
* Resolve the directory where suite summary artifacts are written and read.
*
* Honors {@link PHOENIX_TEST_REPORT_DIR_ENV_VAR} when set. Otherwise it derives
* a directory under the OS temp dir, namespaced by a hash of the current
* working directory so that concurrent test runs in different projects don't
* read each other's artifacts.
*/
function getSuiteSummaryReportDir() {
const configuredReportDir = process.env[exports.PHOENIX_TEST_REPORT_DIR_ENV_VAR];
if (configuredReportDir && configuredReportDir.trim().length > 0) {
return configuredReportDir;
}
const cwdHash = (0, node_crypto_1.createHash)("sha256")
.update(process.cwd())
.digest("hex")
.slice(0, 16);
return (0, node_path_1.join)((0, node_os_1.tmpdir)(), "phoenix-client-test-reports", cwdHash);
}
/**
* Delete any artifacts left over from a previous run. The reporter calls this
* at the start of every run so summaries from an earlier watch-mode invocation
* don't bleed into the next one.
*/
function clearSuiteSummaryArtifacts() {
const reportDir = getSuiteSummaryReportDir();
(0, node_fs_1.mkdirSync)(reportDir, { recursive: true });
for (const fileName of (0, node_fs_1.readdirSync)(reportDir)) {
if (!isArtifactFileName(fileName)) {
continue;
}
try {
(0, node_fs_1.rmSync)((0, node_path_1.join)(reportDir, fileName), { force: true });
}
catch (_a) {
continue;
}
}
}
/**
* Write one suite's summary to the report directory. Called from each worker
* when a suite finishes.
*
* The file is written to a `.tmp` path first and then atomically renamed into
* place, so the reader never observes a half-written artifact. Failures are
* intentionally swallowed — reporting must not break the user's tests.
*/
function writeSuiteSummaryArtifact(suite) {
try {
const reportDir = getSuiteSummaryReportDir();
(0, node_fs_1.mkdirSync)(reportDir, { recursive: true });
const artifact = {
version: ARTIFACT_VERSION,
createdAtMs: Date.now(),
suite: createSuiteSummary(suite),
};
const fileName = `${ARTIFACT_FILE_PREFIX}${process.pid}-${Date.now()}-${artifactCounter++}.json`;
const artifactPath = (0, node_path_1.join)(reportDir, fileName);
const temporaryPath = `${artifactPath}.tmp`;
(0, node_fs_1.writeFileSync)(temporaryPath, JSON.stringify(artifact), "utf8");
(0, node_fs_1.renameSync)(temporaryPath, artifactPath);
}
catch (_a) {
// Reporting artifacts are best-effort and should not fail user tests.
}
}
/**
* Read back all suite summaries the workers wrote during this run, sorted by
* suite name for stable output.
*
* @param sinceMs - When provided, only artifacts created at or after this
* timestamp are returned, so summaries from a previous run that share the
* report directory are ignored. A one-second slack is allowed to absorb
* small clock differences between worker processes.
*/
function readSuiteSummaryArtifacts({ sinceMs, } = {}) {
const reportDir = getSuiteSummaryReportDir();
if (!(0, node_fs_1.existsSync)(reportDir)) {
return [];
}
const suites = [];
for (const fileName of (0, node_fs_1.readdirSync)(reportDir)) {
if (!fileName.startsWith(ARTIFACT_FILE_PREFIX) ||
!fileName.endsWith(".json")) {
continue;
}
const artifactPath = (0, node_path_1.join)(reportDir, fileName);
try {
const stats = (0, node_fs_1.statSync)(artifactPath);
if (sinceMs !== undefined && stats.mtimeMs < sinceMs - 1000) {
continue;
}
const artifact = JSON.parse((0, node_fs_1.readFileSync)(artifactPath, "utf8"));
if (!isSuiteSummaryArtifact(artifact)) {
continue;
}
if (sinceMs !== undefined && artifact.createdAtMs < sinceMs - 1000) {
continue;
}
suites.push(artifact.suite);
}
catch (_a) {
continue;
}
}
return suites.sort((leftSuite, rightSuite) => leftSuite.name.localeCompare(rightSuite.name));
}
/**
* Project the live in-memory {@link SuiteState} down to the serializable
* {@link SuiteSummary} that gets written to disk. Drops anything that can't or
* shouldn't cross the process boundary (clients, tracers, raw Error objects).
*/
function createSuiteSummary(suite) {
return {
name: suite.name,
trackingDisabled: suite.trackingDisabled,
trackingDisabledReason: suite.trackingDisabledReason,
setupError: suite.setupError
? { message: suite.setupError.message }
: undefined,
uploadFailureCount: suite.uploadFailureCount,
results: suite.results.map(createTestResultSummary),
acceptanceResults: suite.acceptanceResults,
links: suite.links.map(({ label, url }) => ({ label, url })),
};
}
function createTestResultSummary(result) {
return {
suiteName: result.suiteName,
testName: result.testName,
status: result.status,
output: toJsonSafeValue(result.output),
annotations: result.annotations.map(({ name, score, label, explanation, annotatorKind, metadata, traceId, }) => ({
name,
score,
label,
explanation,
annotatorKind,
metadata,
traceId,
})),
error: result.error,
durationMs: result.durationMs,
repetitionNumber: result.repetitionNumber,
repetitions: result.repetitions,
dryRun: result.dryRun,
traceId: result.traceId,
runId: result.runId,
exampleId: result.exampleId,
};
}
/**
* Make a recorded test output safe to JSON-serialize: `bigint` values are
* stringified, and anything that still can't be serialized (cycles, etc.)
* falls back to its `String()` form rather than throwing.
*/
function toJsonSafeValue(value) {
if (value === undefined) {
return undefined;
}
try {
return JSON.parse(JSON.stringify(value, (_key, nestedValue) => typeof nestedValue === "bigint" ? nestedValue.toString() : nestedValue));
}
catch (_a) {
return String(value);
}
}
/**
* Validate a parsed artifact before trusting it. Guards against version skew
* and partially-written or unrelated JSON files in the report directory.
*/
function isSuiteSummaryArtifact(value) {
if (!isRecord(value)) {
return false;
}
const suite = value.suite;
return (value.version === ARTIFACT_VERSION &&
typeof value.createdAtMs === "number" &&
isRecord(suite) &&
typeof suite.name === "string" &&
Array.isArray(suite.results) &&
Array.isArray(suite.links));
}
function isArtifactFileName(fileName) {
return (fileName.startsWith(ARTIFACT_FILE_PREFIX) &&
(fileName.endsWith(".json") || fileName.endsWith(".json.tmp")));
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
//# sourceMappingURL=report-artifacts.js.map