magnitude-test
Version:
A TypeScript client for running automated UI tests through the Magnitude testing platform
1,268 lines (1,248 loc) • 43.6 kB
JavaScript
;
var extraTypings = require('@commander-js/extra-typings');
var path = require('node:path');
var fs = require('node:fs');
var glob = require('glob');
var node_worker_threads = require('node:worker_threads');
var stdEnv = require('std-env');
var magnitudeCore = require('magnitude-core');
var version = require('./version-Pmj76mOB.cjs');
var dotenv = require('dotenv');
var node_child_process = require('node:child_process');
var EventEmitter = require('node:events');
var node_timers = require('node:timers');
var logUpdate = require('log-update');
var promises = require('node:timers/promises');
var chalk = require('chalk');
require('pino');
require('node:module');
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var dotenv__namespace = /*#__PURE__*/_interopNamespaceDefault(dotenv);
async function findProjectRoot(startDir = process.cwd()) {
let currentDir = startDir;
const rootDir = path.parse(currentDir).root;
while (currentDir !== rootDir) {
const packagePath = path.join(currentDir, "package.json");
try {
await fs.promises.access(packagePath, fs.constants.F_OK);
return currentDir;
} catch (error) {
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
return null;
}
async function isProjectRoot(dir) {
const packagePath = path.join(dir, "package.json");
try {
await fs.promises.access(packagePath, fs.constants.F_OK);
return true;
} catch (error) {
return false;
}
}
async function discoverTestFiles(patterns, cwd = process.cwd()) {
try {
const files = await glob.glob(patterns, {
cwd,
dot: true,
// Ignore dot files by default
nodir: true,
// Only return files, not directories
absolute: true
// Return paths relative to cwd
});
return files.map((file) => path.resolve(cwd, file));
} catch (error) {
console.error("Error discovering test files:", error);
return [];
}
}
function findConfig(searchRoot) {
try {
const configFiles = glob.glob.sync("**/magnitude.config.{mts,ts}", {
cwd: searchRoot,
ignore: ["**/node_modules/**", "**/dist/**"],
absolute: true
});
return configFiles.length > 0 ? configFiles[0] : null;
} catch (error) {
console.error("Error finding config file:", error);
return null;
}
}
function readConfig(configPath) {
return new Promise((resolve, reject) => {
const worker = new node_worker_threads.Worker(
new URL(
(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href)).endsWith(".ts") ? "../worker/readConfig.ts" : "./worker/readConfig.js",
(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href))
),
{
env: { NODE_ENV: "test", ...process.env },
execArgv: !(stdEnv.isBun || stdEnv.isDeno) ? ["--import=jiti/register"] : []
}
);
worker.once("message", ({ success, config, error }) => {
worker.terminate();
if (success) {
resolve(config);
} else {
reject(new Error(error));
}
});
worker.once("error", (error) => {
worker.terminate();
reject(error);
});
worker.once("online", () => {
worker.postMessage({ configPath });
});
});
}
class WorkerPool {
concurrency;
/**
* Creates an instance of WorkerPool.
* @param concurrency The maximum number of tasks to run concurrently. Must be at least 1.
*/
constructor(concurrency) {
this.concurrency = Math.max(1, concurrency);
}
/**
* Runs the given asynchronous tasks with the specified concurrency.
*
* @template T The type of the result returned by each task.
* @param tasks An array of functions, each returning a Promise<T>. Each function receives an AbortSignal.
* @param checkResultForAbort An optional function that checks the result of a completed task. If it returns true, the pool will abort further processing.
* @returns A Promise resolving to a WorkerPoolResult<T> object.
*/
async runTasks(tasks, checkResultForAbort = () => false) {
const abortController = new AbortController();
const { signal } = abortController;
const taskQueue = tasks.map((task, index) => ({ task, index }));
const results = new Array(tasks.length).fill(void 0);
const runningWorkers = /* @__PURE__ */ new Set();
const runWorker = async () => {
while (taskQueue.length > 0) {
if (signal.aborted) {
break;
}
const taskItem = taskQueue.shift();
if (!taskItem) continue;
const { task, index } = taskItem;
try {
if (signal.aborted) {
continue;
}
const result = await task(signal);
results[index] = result;
if (!signal.aborted && checkResultForAbort(result)) {
abortController.abort();
}
} catch (error) {
results[index] = void 0;
if (!signal.aborted) {
abortController.abort();
}
}
}
};
const startWorkers = () => {
while (runningWorkers.size < this.concurrency && taskQueue.length > 0 && !signal.aborted) {
const workerPromise = runWorker().finally(() => {
runningWorkers.delete(workerPromise);
if (!signal.aborted && taskQueue.length > 0) {
startWorkers();
}
});
runningWorkers.add(workerPromise);
}
};
startWorkers();
while (runningWorkers.size > 0) {
try {
await Promise.race(runningWorkers);
} catch (e) {
console.error("Unexpected error waiting for worker:", e);
if (!signal.aborted) {
abortController.abort();
}
}
}
const completed = !signal.aborted && taskQueue.length === 0;
return { completed, results };
}
}
const TEST_FILE_LOADING_TIMEOUT = 3e4;
class TestSuiteRunner {
runnerConfig;
renderer;
config;
tests;
executors = /* @__PURE__ */ new Map();
workerStoppers = [];
constructor(config) {
this.tests = [];
this.runnerConfig = config;
this.config = config.config;
}
async runTest(test, signal) {
const executor = this.executors.get(test.id);
if (!executor) {
throw new Error(`Test worker not found for test ID: ${test.id}`);
}
try {
return await executor(
{
type: "execute",
test,
browserOptions: this.config.browser,
llm: this.config.llm,
grounding: this.config.grounding,
telemetry: this.config.telemetry
},
(state) => {
this.renderer?.onTestStateUpdated(test, state);
},
signal
);
} catch (err) {
if (signal.aborted) {
return {
passed: false,
failure: {
message: "Test execution aborted"
}
};
}
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(`Unexpected error during test '${test.title}':
${errorMessage}`);
return {
passed: false,
failure: {
message: errorMessage
}
};
}
}
getActiveOptions() {
const envOptions = process.env.MAGNITUDE_TEST_URL ? {
url: process.env.MAGNITUDE_TEST_URL
} : {};
return {
...this.config,
...envOptions,
// env options take precedence over config options
url: version.processUrl(envOptions.url, this.config.url)
};
}
async loadTestFile(absoluteFilePath, relativeFilePath) {
try {
const workerData = {
relativeFilePath,
absoluteFilePath,
options: this.getActiveOptions()
};
const createWorker = stdEnv.isBun ? createBunTestWorker : createNodeTestWorker;
const result = await createWorker(workerData);
this.tests.push(...result.tests);
for (const test of result.tests) {
this.executors.set(test.id, result.executor);
}
this.workerStoppers.push(result.stopper);
} catch (error) {
console.error(`Failed to load test file ${relativeFilePath}:`, error);
throw error;
}
}
async runTests() {
if (!this.tests) throw new Error("No tests were registered");
this.renderer = this.runnerConfig.createRenderer(this.tests);
this.renderer.start?.();
const workerPool = new WorkerPool(this.runnerConfig.workerCount);
let overallSuccess = true;
try {
const poolResult = await workerPool.runTasks(
this.tests.map((test) => (signal) => this.runTest(test, signal)),
this.runnerConfig.failFast ? (taskOutcome) => !taskOutcome.passed : () => false
);
for (const result of poolResult.results) {
if (result === void 0 || !result.passed) {
overallSuccess = false;
break;
}
}
if (!poolResult.completed) {
overallSuccess = false;
}
} catch (error) {
overallSuccess = false;
}
const stopperResults = await Promise.allSettled(
this.workerStoppers.map((stopper) => stopper())
);
const stopperErrors = stopperResults.filter((result) => result.status === "rejected");
if (stopperErrors.length > 0) {
overallSuccess = false;
console.error(`${stopperErrors.length} workers failed to stop`);
for (const error of stopperErrors) {
console.error(error);
}
}
this.renderer.stop?.();
return overallSuccess;
}
}
const createNodeTestWorker = async (workerData) => new Promise((resolve, reject) => {
const { relativeFilePath } = workerData;
const worker = new node_worker_threads.Worker(
new URL(
(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href)).endsWith(".ts") ? "../worker/readTest.ts" : "./worker/readTest.js",
(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href))
),
{
workerData,
env: { NODE_ENV: "test", ...process.env },
execArgv: !(stdEnv.isBun || stdEnv.isDeno) ? ["--import=jiti/register"] : []
}
);
let hasRunTests = false;
const stopper = async () => {
if (!hasRunTests) {
worker.terminate();
return;
}
worker.postMessage({ type: "graceful_shutdown" });
return new Promise((resolve2, reject2) => {
const shutdownHandler = (msg) => {
if (msg.type === "graceful_shutdown_complete") {
worker.off("message", shutdownHandler);
worker.off("error", errorHandler);
worker.off("exit", exitHandler);
worker.terminate();
resolve2();
}
};
const errorHandler = (error) => {
worker.off("message", shutdownHandler);
worker.off("error", errorHandler);
worker.off("exit", exitHandler);
reject2(new Error(`Worker error during shutdown: ${error.message}`));
};
const exitHandler = (code) => {
worker.off("message", shutdownHandler);
worker.off("error", errorHandler);
worker.off("exit", exitHandler);
reject2(new Error(`Worker exited unexpectedly during shutdown with code: ${code}`));
};
worker.on("message", shutdownHandler);
worker.on("error", errorHandler);
worker.on("exit", exitHandler);
});
};
const executor = (executeMessage, onStateChange, signal) => new Promise((res, rej) => {
const messageHandler = (msg) => {
if ("test" in executeMessage && "testId" in msg && msg.testId !== executeMessage.test.id) return;
hasRunTests = true;
if (msg.type === "test_result") {
worker.off("message", messageHandler);
res(msg.result);
} else if (msg.type === "test_error") {
worker.off("message", messageHandler);
rej(new Error(msg.error));
} else if (msg.type === "test_state_change") {
onStateChange(msg.state);
}
};
signal.addEventListener("abort", () => {
worker.off("message", messageHandler);
rej(new Error("Test execution aborted"));
});
worker.on("message", messageHandler);
worker.postMessage(executeMessage);
});
const registeredTests = [];
worker.on("message", (message) => {
if (message.type === "registered") {
registeredTests.push(message.test);
} else if (message.type === "load_error") {
clearTimeout(timeout);
worker.terminate();
reject(new Error(`Failed to load ${relativeFilePath}: ${message.error}`));
} else if (message.type === "load_complete") {
clearTimeout(timeout);
if (!registeredTests.length) {
reject(new Error(`No tests registered for file ${relativeFilePath}`));
return;
}
resolve({ tests: registeredTests, executor, stopper });
}
});
const timeout = setTimeout(() => {
worker.terminate();
reject(new Error(`Test file loading timeout: ${relativeFilePath}`));
}, TEST_FILE_LOADING_TIMEOUT);
worker.on("error", (error) => {
clearTimeout(timeout);
worker.terminate();
reject(error);
});
});
const createBunTestWorker = async (workerData) => new Promise((resolve, reject) => {
const { relativeFilePath } = workerData;
const emit = new EventEmitter.EventEmitter();
const proc = Bun.spawn({
cmd: [
"bun",
new URL(
(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href)).endsWith(".ts") ? "../worker/readTest.ts" : "./worker/readTest.js",
(typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href))
).pathname
],
env: {
NODE_ENV: "test",
...process.env,
MAGNITUDE_WORKER_DATA: JSON.stringify(workerData)
},
cwd: process.cwd(),
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
// "advanced" serialization in Bun somehow isn't able to clone test state messages?
serialization: "json",
ipc(message) {
emit.emit("message", message);
},
onExit(_subprocess, exitCode) {
if (exitCode !== 0) {
clearTimeout(timeout);
reject(new Error(`Worker process exited with code ${exitCode}`));
}
}
});
let hasRunTests = false;
const stopper = async () => {
if (!hasRunTests) {
proc.kill("SIGKILL");
return;
}
proc.send({ type: "graceful_shutdown" });
return new Promise((resolve2, reject2) => {
const shutdownHandler = (msg) => {
if (msg.type === "graceful_shutdown_complete") {
emit.off("message", shutdownHandler);
proc.kill("SIGKILL");
resolve2();
}
};
const exitHandler = (code) => {
emit.off("message", shutdownHandler);
reject2(new Error(`Bun worker exited unexpectedly during shutdown with code: ${code}`));
};
emit.on("message", shutdownHandler);
proc.exited.then(exitHandler);
});
};
const executor = (executeMessage, onStateChange, signal) => new Promise((res, rej) => {
const messageHandler = (msg) => {
if ("test" in executeMessage && "testId" in msg && msg.testId !== executeMessage.test.id) return;
hasRunTests = true;
if (msg.type === "test_result") {
emit.off("message", messageHandler);
res(msg.result);
} else if (msg.type === "test_error") {
emit.off("message", messageHandler);
rej(new Error(msg.error));
} else if (msg.type === "test_state_change") {
onStateChange(msg.state);
}
};
emit.on("message", messageHandler);
signal.addEventListener("abort", () => {
emit.off("message", messageHandler);
rej(new Error("Test execution aborted"));
});
proc.send(executeMessage);
});
const registeredTests = [];
emit.on("message", (message) => {
if (message.type === "registered") {
registeredTests.push(message.test);
} else if (message.type === "load_error") {
clearTimeout(timeout);
proc.kill();
reject(new Error(`Failed to load ${relativeFilePath}: ${message.error}`));
} else if (message.type === "load_complete") {
clearTimeout(timeout);
if (!registeredTests.length) {
reject(new Error(`No tests registered for file ${relativeFilePath}`));
return;
}
resolve({ tests: registeredTests, executor, stopper });
}
});
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error(`Test file loading timeout: ${relativeFilePath}`));
}, TEST_FILE_LOADING_TIMEOUT);
});
const ANSI_RESET = "\x1B[0m";
const ANSI_GREEN = "\x1B[32m";
const ANSI_BRIGHT_BLUE = "\x1B[94m";
const ANSI_GRAY = "\x1B[90m";
const ANSI_RED = "\x1B[31m";
const ANSI_BOLD = "\x1B[1m";
const ANSI_DIM = "\x1B[2m";
const spinnerChars$1 = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
const spinnerChars = spinnerChars$1;
let redrawScheduled = false;
let renderSettings = { showActions: true };
let timerInterval = null;
let currentTestStates = {};
let allRegisteredTests = [];
let currentModel = "";
let elapsedTimes = {};
let isFinished = false;
let spinnerFrame = 0;
function resetState() {
redrawScheduled = false;
renderSettings = { showActions: true };
timerInterval = null;
currentTestStates = {};
allRegisteredTests = [];
currentModel = "";
elapsedTimes = {};
isFinished = false;
spinnerFrame = 0;
}
function setRedrawScheduled(value) {
redrawScheduled = value;
}
function setCurrentModel(model) {
currentModel = model;
}
function setAllRegisteredTests(tests) {
allRegisteredTests = tests;
}
function setCurrentTestStates(states) {
currentTestStates = states;
}
function setTimerInterval(interval) {
timerInterval = interval;
}
function setSpinnerFrame(frame) {
spinnerFrame = frame;
}
function setElapsedTimes(times) {
elapsedTimes = times;
}
function updateElapsedTime(testId, time) {
elapsedTimes[testId] = time;
}
function setIsFinished(value) {
isFinished = value;
}
function setRenderSettings(settings) {
renderSettings = settings;
}
function formatDuration(ms) {
if (ms === void 0 || ms === null) {
return "";
}
return `${(ms / 1e3).toFixed(1)}s`;
}
function getTestStatusIndicatorChar(status) {
switch (status) {
case "passed":
return "\u2713";
case "failed":
return "\u2715";
case "cancelled":
return "\u2298";
case "pending":
default:
return "\u25CC";
}
}
function getStepStatusIndicatorChar(status) {
switch (status) {
case "running":
return ">";
case "passed":
return "\u2691";
case "failed":
return "\u2715";
case "cancelled":
return "\u2298";
case "pending":
default:
return "\u2022";
}
}
function getCheckStatusIndicatorChar(status) {
switch (status) {
case "running":
return "?";
case "passed":
return "\u2713";
case "failed":
return "\u2715";
case "cancelled":
return "\u2298";
case "pending":
default:
return "\u2022";
}
}
function styleAnsi(status, text, type) {
let colorCode = ANSI_GRAY;
switch (type) {
case "test":
switch (status) {
case "running":
colorCode = ANSI_BRIGHT_BLUE;
break;
case "passed":
colorCode = ANSI_GREEN;
break;
case "failed":
colorCode = ANSI_RED;
break;
case "cancelled":
colorCode = ANSI_GRAY;
break;
}
break;
case "step":
switch (status) {
case "running":
colorCode = ANSI_GRAY;
break;
case "passed":
colorCode = ANSI_BRIGHT_BLUE;
break;
case "failed":
colorCode = ANSI_RED;
break;
case "cancelled":
colorCode = ANSI_GRAY;
break;
}
break;
case "check":
switch (status) {
case "running":
colorCode = ANSI_GRAY;
break;
case "passed":
colorCode = ANSI_BRIGHT_BLUE;
break;
case "failed":
colorCode = ANSI_RED;
break;
case "cancelled":
colorCode = ANSI_GRAY;
break;
}
break;
}
return `${colorCode}${text}${ANSI_RESET}`;
}
const UI_LEFT_PADDING = " ";
function generateTitleBarString() {
const titleText = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}Magnitude v${version.VERSION}${ANSI_RESET}`;
const modelText = `${ANSI_GRAY}${currentModel}${ANSI_RESET}`;
return [`${UI_LEFT_PADDING}${titleText} ${modelText}`];
}
function generateFailureString(failure, indent) {
const output = [];
const prefix = "\u21B3 ";
const prefixAnsi = `${ANSI_RED}${prefix}${ANSI_RESET}`;
const addLine = (text, styleCode = ANSI_RED, bold = false) => {
const fullStyleCode = `${styleCode}${bold ? ANSI_BOLD : ""}`;
output.push(UI_LEFT_PADDING + " ".repeat(indent) + prefixAnsi + `${fullStyleCode}${text}${ANSI_RESET}`);
};
if (failure && failure.message) {
addLine(failure.message);
} else {
addLine("Unknown error details");
}
return output;
}
function generateTestString(test, state, indent) {
const output = [];
const testId = test.id;
const stepIndent = indent + 2;
const actionIndent = stepIndent + 2;
const currentStatus = state.status;
const statusCharPlain = currentStatus === "running" ? spinnerChars[spinnerFrame] : getTestStatusIndicatorChar(currentStatus);
const statusStyled = styleAnsi(currentStatus, statusCharPlain, "test");
const timerText = currentStatus !== "pending" ? `${ANSI_GRAY} [${formatDuration(elapsedTimes[testId] ?? 0)}]${ANSI_RESET}` : "";
output.push(UI_LEFT_PADDING + " ".repeat(indent) + `${statusStyled} ${test.title}${timerText}`);
if (state.stepsAndChecks && state.stepsAndChecks.length > 0) {
state.stepsAndChecks.forEach((item) => {
let itemCharPlain = "";
let itemDesc = "";
let itemStyleType = "step";
if (item.variant === "step") {
itemCharPlain = getStepStatusIndicatorChar(item.status);
itemDesc = item.description;
itemStyleType = "step";
} else {
itemCharPlain = getCheckStatusIndicatorChar(item.status);
itemDesc = item.description;
itemStyleType = "check";
}
const styledChar = styleAnsi(item.status, itemCharPlain, itemStyleType);
output.push(UI_LEFT_PADDING + " ".repeat(stepIndent) + `${styledChar} ${itemDesc}`);
if (renderSettings.showActions && item.variant === "step" && item.actions.length > 0) {
item.actions.forEach((action) => {
output.push(UI_LEFT_PADDING + " ".repeat(actionIndent) + `${ANSI_GRAY}${action.pretty}${ANSI_RESET}`);
});
}
});
}
if (state.failure) {
const failureLines = generateFailureString(state.failure, stepIndent);
output.push(...failureLines);
}
return output;
}
function groupRegisteredTestsForDisplay(tests) {
const files = {};
for (const test of tests) {
if (!files[test.filepath]) {
files[test.filepath] = { ungrouped: [], groups: {} };
}
if (test.group) {
if (!files[test.filepath].groups[test.group]) {
files[test.filepath].groups[test.group] = [];
}
files[test.filepath].groups[test.group].push(test);
} else {
files[test.filepath].ungrouped.push(test);
}
}
return files;
}
function generateTestListString() {
const output = [];
const fileIndent = 0;
const groupIndent = fileIndent + 2;
const testBaseIndent = groupIndent;
const groupedDisplayTests = groupRegisteredTestsForDisplay(allRegisteredTests);
for (const [filepath, { ungrouped, groups }] of Object.entries(groupedDisplayTests)) {
const fileHeader = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}\u2630 ${filepath}${ANSI_RESET}`;
output.push(UI_LEFT_PADDING + " ".repeat(fileIndent) + fileHeader);
if (ungrouped.length > 0) {
for (const test of ungrouped) {
const state = currentTestStates[test.id];
if (state) {
const testLines = generateTestString(test, state, testBaseIndent);
output.push(...testLines);
}
}
}
if (Object.entries(groups).length > 0) {
for (const [groupName, groupTests] of Object.entries(groups)) {
const groupHeader = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}\u21B3 ${groupName}${ANSI_RESET}`;
output.push(UI_LEFT_PADDING + " ".repeat(groupIndent) + groupHeader);
for (const test of groupTests) {
const state = currentTestStates[test.id];
if (state) {
const testLines = generateTestString(test, state, testBaseIndent + 2);
output.push(...testLines);
}
}
}
}
output.push(UI_LEFT_PADDING);
}
return output;
}
function generateSummaryString() {
const output = [];
let totalInputTokens = 0;
let totalOutputTokens = 0;
const statusCounts = { pending: 0, running: 0, passed: 0, failed: 0, cancelled: 0, total: 0 };
const failuresWithContext = [];
const testContextMap = /* @__PURE__ */ new Map();
allRegisteredTests.forEach((test) => {
testContextMap.set(test.id, { filepath: test.filepath, groupName: test.group, testTitle: test.title });
});
Object.entries(currentTestStates).forEach(([testId, state]) => {
statusCounts.total++;
statusCounts[state.status]++;
if (state.modelUsage.length > 0) {
totalInputTokens += state.modelUsage[0].inputTokens;
totalOutputTokens += state.modelUsage[0].outputTokens;
}
if (state.failure) {
const context = testContextMap.get(testId);
failuresWithContext.push({
filepath: context?.filepath ?? "Unknown File",
groupName: context?.groupName,
testTitle: context?.testTitle ?? "Unknown Test",
failure: state.failure
});
}
});
const hasFailures = failuresWithContext.length > 0;
let statusLine = "";
if (statusCounts.passed > 0) statusLine += `${ANSI_GREEN}\u2713 ${statusCounts.passed} passed${ANSI_RESET} `;
if (statusCounts.failed > 0) statusLine += `${ANSI_RED}\u2717 ${statusCounts.failed} failed${ANSI_RESET} `;
if (statusCounts.running > 0) statusLine += `${ANSI_BRIGHT_BLUE}\u25B7 ${statusCounts.running} running${ANSI_RESET} `;
if (statusCounts.pending > 0) statusLine += `${ANSI_GRAY}\u25CC ${statusCounts.pending} pending${ANSI_RESET} `;
if (statusCounts.cancelled > 0) statusLine += `${ANSI_GRAY}\u2298 ${statusCounts.cancelled} cancelled${ANSI_RESET} `;
let costDescription = "";
for (const [model, costs] of Object.entries(version.knownCostMap)) {
if (currentModel.includes(model)) {
const inputCost = costs[0];
const outputCost = costs[1];
costDescription = ` ($${((totalInputTokens * inputCost + totalOutputTokens * outputCost) / 1e6).toFixed(2)})`;
}
}
let tokenText = `${ANSI_GRAY}tokens: ${totalInputTokens} in, ${totalOutputTokens} out${costDescription}${ANSI_RESET}`;
output.push(UI_LEFT_PADDING + statusLine.trimEnd() + (statusLine && tokenText ? " " : "") + tokenText.trimStart());
if (hasFailures) {
output.push(UI_LEFT_PADDING + `${ANSI_DIM}Failures:${ANSI_RESET}`);
for (const { filepath, groupName, testTitle, failure } of failuresWithContext) {
const contextString = `${ANSI_DIM}${filepath}${groupName ? ` > ${groupName}` : ""} > ${testTitle}${ANSI_RESET}`;
output.push(UI_LEFT_PADDING + UI_LEFT_PADDING + contextString);
const failureLines = generateFailureString(failure, 4);
output.push(...failureLines);
output.push(UI_LEFT_PADDING);
}
}
return output;
}
function calculateTestListHeight(tests, testStates) {
let height = 0;
const groupedDisplayTests = groupRegisteredTestsForDisplay(tests);
for (const [filepath, { ungrouped, groups }] of Object.entries(groupedDisplayTests)) {
height++;
if (ungrouped.length > 0) {
for (const test of ungrouped) {
const state = testStates[test.id];
if (state) {
height++;
if (state.stepsAndChecks) {
state.stepsAndChecks.forEach((item) => {
height++;
if (renderSettings.showActions && item.variant === "step" && item.actions.length > 0) {
item.actions.forEach(() => height++);
}
});
}
if (state.failure) {
height++;
}
}
}
}
if (Object.entries(groups).length > 0) {
for (const [groupName, groupTests] of Object.entries(groups)) {
height++;
for (const test of groupTests) {
const state = testStates[test.id];
if (state) {
height++;
if (state.stepsAndChecks) {
state.stepsAndChecks.forEach((item) => {
height++;
if (renderSettings.showActions && item.variant === "step" && item.actions.length > 0) {
item.actions.forEach(() => height++);
}
});
}
if (state.failure) {
height++;
}
}
}
}
}
height++;
}
return height;
}
function calculateSummaryHeight(testStates) {
let height = 0;
height++;
const failuresExist = Object.values(testStates).some((state) => !!state.failure);
if (failuresExist) {
height++;
Object.values(testStates).forEach((state) => {
if (state.failure) {
height++;
height++;
height++;
}
});
}
return height;
}
function redraw() {
setRedrawScheduled(false);
let testListLineCount = calculateTestListHeight(allRegisteredTests, currentTestStates);
let summaryLineCount = calculateSummaryHeight(currentTestStates);
if (Object.values(currentTestStates).length === 0) {
summaryLineCount = 0;
testListLineCount = 0;
}
const outputLines = [];
outputLines.push(...generateTitleBarString());
outputLines.push(UI_LEFT_PADDING);
if (testListLineCount > 0) {
outputLines.push(...generateTestListString());
}
if (summaryLineCount > 0) {
if (testListLineCount > 0) outputLines.push(UI_LEFT_PADDING);
outputLines.push(...generateSummaryString());
}
const frameContent = outputLines.join("\n");
logUpdate.clear();
logUpdate(frameContent);
if (isFinished) {
logUpdate.done();
process.stderr.write("\n");
}
}
function scheduleRedraw() {
if (!redrawScheduled) {
setRedrawScheduled(true);
node_timers.setImmediate(redraw);
}
}
class TermAppRenderer {
magnitudeConfig;
initialTests;
firstModelReportedInUI = false;
// New flag
// To manage SIGINT listener
sigintListener = null;
constructor(config, initialTests) {
this.magnitudeConfig = config;
this.initialTests = [...initialTests];
if (this.magnitudeConfig.display?.showActions !== void 0) {
setRenderSettings({ showActions: this.magnitudeConfig.display.showActions });
}
setCurrentModel("");
}
start() {
process.stdout.write("\n");
resetState();
if (this.magnitudeConfig.display?.showActions !== void 0) {
setRenderSettings({ showActions: this.magnitudeConfig.display.showActions });
}
this.firstModelReportedInUI = false;
setAllRegisteredTests(this.initialTests);
const initialTestStates = {};
for (const test of this.initialTests) {
initialTestStates[test.id] = {
status: "pending",
// Add initial status
stepsAndChecks: [],
modelUsage: []
// macroUsage: { provider: '', model: '', inputTokens: 0, outputTokens: 0, numCalls: 0 },
// microUsage: { provider: '', numCalls: 0 },
};
}
setCurrentTestStates(initialTestStates);
setElapsedTimes({});
this.sigintListener = this.handleExitKeyPress.bind(this);
process.on("SIGINT", this.sigintListener);
if (!timerInterval) {
const interval = setInterval(() => {
if (isFinished) {
clearInterval(timerInterval);
setTimerInterval(null);
return;
}
let runningTestsExist = false;
setSpinnerFrame((spinnerFrame + 1) % spinnerChars.length);
Object.entries(currentTestStates).forEach(([testId, state]) => {
const liveState = state;
if (liveState.status === "running") {
runningTestsExist = true;
if (liveState.startedAt) {
updateElapsedTime(testId, Date.now() - liveState.startedAt);
}
}
});
if (runningTestsExist && !redrawScheduled) {
scheduleRedraw();
}
}, 100);
setTimerInterval(interval);
}
scheduleRedraw();
}
stop() {
if (isFinished) return;
setIsFinished(true);
if (timerInterval) {
clearInterval(timerInterval);
setTimerInterval(null);
}
if (this.sigintListener) {
process.removeListener("SIGINT", this.sigintListener);
this.sigintListener = null;
}
redraw();
}
onTestStateUpdated(test, newState) {
const currentStates = { ...currentTestStates };
const testId = test.id;
const existingState = currentStates[testId] || {};
const updatedTestState = {
...existingState,
...newState,
startedAt: newState.startedAt || existingState.startedAt
};
currentStates[testId] = updatedTestState;
setCurrentTestStates(currentStates);
if (!this.firstModelReportedInUI && newState.modelUsage && newState.modelUsage.length > 0) {
const firstModelEntry = newState.modelUsage[0];
let modelNameToReport = void 0;
if (firstModelEntry && firstModelEntry.llm) {
modelNameToReport = version.describeModel(firstModelEntry.llm);
}
if (modelNameToReport) {
setCurrentModel(modelNameToReport);
this.firstModelReportedInUI = true;
}
}
if (updatedTestState.startedAt && !updatedTestState.doneAt) {
if (!elapsedTimes[testId] || elapsedTimes[testId] === 0) {
updateElapsedTime(testId, Date.now() - updatedTestState.startedAt);
}
} else if (updatedTestState.startedAt && updatedTestState.doneAt) {
updateElapsedTime(testId, updatedTestState.doneAt - updatedTestState.startedAt);
}
scheduleRedraw();
}
// onResize method removed as per user request.
// Adapted from term-app/index.ts
handleExitKeyPress() {
this.stop();
}
}
async function isServerRunning(url) {
try {
await fetch(url, { method: "HEAD" });
return true;
} catch {
return false;
}
}
async function startWebServer(config) {
const { command, url, timeout = 6e4, reuseExistingServer = false } = config;
if (reuseExistingServer && await isServerRunning(url)) {
return null;
}
const child = node_child_process.spawn(command, { shell: true, stdio: "inherit" });
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await isServerRunning(url)) {
return child;
}
await promises.setTimeout(500);
}
child.kill();
throw new Error(`Timed out waiting for web server at ${url}`);
}
function stopWebServer(proc) {
if (proc) {
proc.kill();
}
}
async function startWebServers(configs) {
if (Array.isArray(configs)) {
const procs = [];
for (const config of configs) {
procs.push(await startWebServer(config));
}
return procs;
} else {
return [await startWebServer(configs)];
}
}
function stopWebServers(procs) {
for (const proc of procs) {
stopWebServer(proc);
}
}
function getRelativePath(projectRoot, absolutePath) {
const normalizedAbsolutePath = path.normalize(absolutePath);
const normalizedProjectRoot = path.normalize(projectRoot);
if (!normalizedAbsolutePath.startsWith(normalizedProjectRoot)) {
return absolutePath;
}
return path.relative(normalizedProjectRoot, normalizedAbsolutePath);
}
const configTemplate = `import { type MagnitudeConfig } from 'magnitude-test';
// Learn more about configuring Magnitude:
// https://docs.magnitude.run/customizing/configuration
export default {
url: "http://localhost:5173"
} satisfies MagnitudeConfig;
`;
const exampleTestTemplate = `import { test } from 'magnitude-test';
// Learn more about building test case:
// https://docs.magnitude.run/core-concepts/building-test-cases
const sampleTodos = [
"Take out the trash",
"Pay AWS bill",
"Build more test cases with Magnitude"
];
test('can add and complete todos', { url: 'https://magnitodo.com' }, async (agent) => {
await agent.act('create 3 todos', { data: sampleTodos.join(', ') });
await agent.check('should see all 3 todos');
await agent.act('mark each todo complete');
await agent.check('says 0 items left');
});
`;
async function initializeProject(force = false, destination = "tests/magnitude") {
const cwd = process.cwd();
const isNodeProject = await isProjectRoot(cwd);
if (!isNodeProject && !force) {
console.error("Couldn't find package.json in current directory, please initialize Magnitude in a node.js project");
console.error("To override this check, use --force option");
process.exit(1);
}
console.log(chalk.blueBright(`Initializing Magnitude tests in ${cwd}`));
const testsDir = path.join(cwd, destination);
const configPath = path.join(testsDir, "magnitude.config.ts");
if (fs.existsSync(configPath)) {
console.error("Already initialized, magnitude.config.ts already exists!");
process.exit(1);
}
try {
await fs.promises.mkdir(testsDir, { recursive: true });
await fs.promises.writeFile(configPath, configTemplate);
const examplePath = path.join(testsDir, "example.mag.ts");
await fs.promises.writeFile(examplePath, exampleTestTemplate);
console.log(`${chalk.blueBright("\u2713")} Created Magnitude test directory structure:
- ${path.relative(cwd, configPath)}
- ${path.relative(cwd, examplePath)}
`);
} catch (error) {
console.error("Error initializing Magnitude project:", error);
process.exit(1);
}
console.log(chalk.blueBright("Installing Playwright Chromium..."));
try {
node_child_process.execSync("npx playwright install chromium", { stdio: "inherit" });
console.log(`${chalk.blueBright("\u2713")} Playwright Chromium installed successfully`);
} catch (error) {
console.error("Error installing Playwright Chromium:", error);
console.log(chalk.blueBright("You may need to manually run: npx playwright install chromium"));
}
console.log(`You can now run tests with: ${chalk.blueBright("npx magnitude")}`);
console.log("Docs:", chalk.blueBright("https://docs.magnitude.run"));
}
const program = new extraTypings.Command();
program.name("magnitude").description("Run Magnitude test cases").argument("[filter]", "glob pattern for test files (quote if contains spaces or wildcards)").option("-w, --workers <number>", "number of parallel workers for test execution", "1").option("-p, --plain", "disable pretty output and print lines instead").option("-d, --debug", "enable debug logs").option("--no-fail-fast", "continue running tests even if some fail").action(async (filter, options) => {
dotenv__namespace.config();
let logLevel;
if (process.env.MAGNITUDE_LOG_LEVEL) {
logLevel = process.env.MAGNITUDE_LOG_LEVEL;
} else if (options.debug) {
logLevel = "trace";
} else if (options.plain) {
logLevel = "info";
} else {
logLevel = "warn";
}
magnitudeCore.logger.level = logLevel;
version.logger.level = logLevel;
const patterns = [
"!**/node_modules/**",
"!**/dist/**"
];
if (filter) {
patterns.push(filter);
} else {
patterns.push("**/*.{mag,magnitude}.{js,jsx,ts,tsx}");
}
const workerCount = options.workers ? parseInt(options.workers, 10) : 1;
if (isNaN(workerCount) || workerCount < 1) {
console.error("Invalid worker count. Using default of 1.");
}
const absoluteFilePaths = await discoverTestFiles(patterns);
if (absoluteFilePaths.length === 0) {
console.error(`No test files found matching patterns: ${patterns.join(", ")}`);
process.exit(1);
}
const projectRoot = await findProjectRoot() ?? process.cwd();
const configPath = findConfig(projectRoot);
const config = configPath ? await readConfig(configPath) : {};
let webServerProcesses = [];
if (config.webServer) {
try {
webServerProcesses = await startWebServers(config.webServer);
const cleanup = () => stopWebServers(webServerProcesses);
process.on("exit", cleanup);
process.on("SIGINT", () => {
cleanup();
process.exit(1);
});
} catch (err) {
console.error("Error starting web server(s):", err);
process.exit(1);
}
}
const showUI = !options.debug && !options.plain;
const testSuiteRunner = new TestSuiteRunner({
config,
workerCount,
failFast: options.failFast === false ? false : !config.continueAfterFailure,
createRenderer: (tests) => showUI ? new TermAppRenderer(config, tests) : {
// Plain/debug renderer
onTestStateUpdated: (test, state) => {
version.logger.info(`Test: ${test.title} (${test.id})`);
version.logger.info(` Status: ${state.failure ? "failed" : state.doneAt ? "passed" : state.startedAt ? "running" : "pending"}`);
if (state.failure) {
version.logger.error(` Failure: ${state.failure.message}`);
}
}
}
});
for (const filePath of absoluteFilePaths) {
await testSuiteRunner.loadTestFile(filePath, getRelativePath(projectRoot, filePath));
}
try {
const overallSuccess = await testSuiteRunner.runTests();
process.exit(overallSuccess ? 0 : 1);
} catch (error) {
version.logger.error("Test suite execution failed:", error);
process.exit(1);
}
});
program.command("init").description("Initialize Magnitude test directory structure").option("-f, --force", "force initialization even if no package.json is found").option("--dir, --destination <path>", "destination directory for Magnitude tests", "tests/magnitude").action(async (options) => {
await initializeProject(options.force, options.destination);
});
program.parse();