UNPKG

magnitude-test

Version:

A TypeScript client for running automated UI tests through the Magnitude testing platform

1,247 lines (1,230 loc) 41.3 kB
#!/usr/bin/env node import { Command } from '@commander-js/extra-typings'; import path from 'node:path'; import fs from 'node:fs'; import { glob } from 'glob'; import { Worker } from 'node:worker_threads'; import { isBun, isDeno } from 'std-env'; import { logger } from 'magnitude-core'; import { p as processUrl, k as knownCostMap, V as VERSION, d as describeModel, l as logger$1 } from './version-DtiDJ6_4.js'; import * as dotenv from 'dotenv'; import { spawn, execSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { setImmediate } from 'node:timers'; import logUpdate from 'log-update'; import { setTimeout as setTimeout$1 } from 'node:timers/promises'; import chalk from 'chalk'; import 'pino'; import 'node:module'; 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(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.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 Worker( new URL( import.meta.url.endsWith(".ts") ? "../worker/readConfig.ts" : "./worker/readConfig.js", import.meta.url ), { env: { NODE_ENV: "test", ...process.env }, execArgv: !(isBun || 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: processUrl(envOptions.url, this.config.url) }; } async loadTestFile(absoluteFilePath, relativeFilePath) { try { const workerData = { relativeFilePath, absoluteFilePath, options: this.getActiveOptions() }; const createWorker = 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 Worker( new URL( import.meta.url.endsWith(".ts") ? "../worker/readTest.ts" : "./worker/readTest.js", import.meta.url ), { workerData, env: { NODE_ENV: "test", ...process.env }, execArgv: !(isBun || 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(); const proc = Bun.spawn({ cmd: [ "bun", new URL( import.meta.url.endsWith(".ts") ? "../worker/readTest.ts" : "./worker/readTest.js", import.meta.url ).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}${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(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); 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 = 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 = spawn(command, { shell: true, stdio: "inherit" }); const startTime = Date.now(); while (Date.now() - startTime < timeout) { if (await isServerRunning(url)) { return child; } await setTimeout$1(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 { 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 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.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"; } logger.level = logLevel; logger$1.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) => { logger$1.info(`Test: ${test.title} (${test.id})`); logger$1.info(` Status: ${state.failure ? "failed" : state.doneAt ? "passed" : state.startedAt ? "running" : "pending"}`); if (state.failure) { logger$1.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) { logger$1.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();