UNPKG

magnitude-test

Version:

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

329 lines (328 loc) 15 kB
import { setImmediate } from "node:timers"; import logUpdate from 'log-update'; import { VERSION } from '@/version'; import { formatDuration } from './util'; // wrapText removed import { ANSI_RESET, ANSI_GREEN, ANSI_BRIGHT_BLUE, ANSI_GRAY, ANSI_RED, ANSI_BOLD, ANSI_DIM } from './constants'; // PADDING, BOX_CHARS_ROUNDED removed import { styleAnsi, getTestStatusIndicatorChar, getStepStatusIndicatorChar, getCheckStatusIndicatorChar } from './drawingUtils'; // createBoxAnsi, insertLineIntoBoxAnsi removed import { // currentWidth, // Will be unused if all wrapping is gone (now removed from uiState) redrawScheduled, currentTestStates, allRegisteredTests, currentModel, elapsedTimes, isFinished, spinnerFrame, isFirstDraw, /* isResizing, */ renderSettings, // isResizing removed from uiState setRedrawScheduled, setLastOutputLineCount, setIsFirstDraw, spinnerChars } from './uiState'; import { knownCostMap } from '@/util'; const UI_LEFT_PADDING = ' '; /** * Generate the title bar portion of the UI * @returns Array of strings with ANSI codes representing the title bar */ export function generateTitleBarString() { const titleText = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}Magnitude v${VERSION}${ANSI_RESET}`; const modelText = `${ANSI_GRAY}${currentModel}${ANSI_RESET}`; // Simple single line for title bar, no complex padding or width calculations return [`${UI_LEFT_PADDING}${titleText} ${modelText}`]; } /** * Generate a string representation of a failure */ export function generateFailureString(failure, indent) { const output = []; const prefix = '↳ '; const prefixAnsi = `${ANSI_RED}${prefix}${ANSI_RESET}`; const addLine = (text, styleCode = ANSI_RED, bold = false) => { const fullStyleCode = `${styleCode}${bold ? ANSI_BOLD : ''}`; // No wrapping, text is a single line 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; } /** * Generate a string representation of a test */ export 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}` : ''; // No wrapping for title 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 { // Check itemCharPlain = getCheckStatusIndicatorChar(item.status); itemDesc = item.description; itemStyleType = 'check'; } const styledChar = styleAnsi(item.status, itemCharPlain, itemStyleType); // No wrapping for description output.push(UI_LEFT_PADDING + ' '.repeat(stepIndent) + `${styledChar} ${itemDesc}`); if (renderSettings.showActions && item.variant === 'step' && item.actions.length > 0) { item.actions.forEach((action) => { // const actionSymbol = `${ANSI_GRAY}▶${ANSI_RESET}`; // const actionDesc = JSON.stringify(action); // No wrapping for action description output.push(UI_LEFT_PADDING + ' '.repeat(actionIndent) + `${ANSI_GRAY}${action.pretty}${ANSI_RESET}`); //`${actionSymbol} ${ANSI_GRAY}${actionDesc}${ANSI_RESET}`); }); } }); } if (state.failure) { const failureLines = generateFailureString(state.failure, stepIndent); output.push(...failureLines); } return output; } // Helper function to group tests for display 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; } /** * Generate the test list portion of the UI */ export 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}${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}${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); // Blank line between files/main groups } return output; } /** * Generate the summary portion of the UI */ export 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 = 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}${statusCounts.passed} passed${ANSI_RESET} `; if (statusCounts.failed > 0) statusLine += `${ANSI_RED}${statusCounts.failed} failed${ANSI_RESET} `; if (statusCounts.running > 0) statusLine += `${ANSI_BRIGHT_BLUE}${statusCounts.running} running${ANSI_RESET} `; if (statusCounts.pending > 0) statusLine += `${ANSI_GRAY}${statusCounts.pending} pending${ANSI_RESET} `; if (statusCounts.cancelled > 0) statusLine += `${ANSI_GRAY}${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) / 1000000).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); // Indent context further with prepended spaces const failureLines = generateFailureString(failure, 4); // generateFailureString already adds padding output.push(...failureLines); output.push(UI_LEFT_PADDING); // Blank line after each failure with prepended spaces } } return output; } /** * Calculate the height needed for the test list (now just line count) */ export function calculateTestListHeight(tests, testStates) { let height = 0; const groupedDisplayTests = groupRegisteredTestsForDisplay(tests); for (const [filepath, { ungrouped, groups }] of Object.entries(groupedDisplayTests)) { height++; // File header line if (ungrouped.length > 0) { for (const test of ungrouped) { const state = testStates[test.id]; if (state) { height++; // Test title line if (state.stepsAndChecks) { state.stepsAndChecks.forEach((item) => { height++; // Item description line if (renderSettings.showActions && item.variant === 'step' && item.actions.length > 0) { item.actions.forEach(() => height++); } }); } if (state.failure) { height++; // generateFailureString returns 1 line } } } } if (Object.entries(groups).length > 0) { for (const [groupName, groupTests] of Object.entries(groups)) { height++; // Group header line for (const test of groupTests) { const state = testStates[test.id]; if (state) { height++; // Test title line if (state.stepsAndChecks) { state.stepsAndChecks.forEach((item) => { height++; // Item description line if (renderSettings.showActions && item.variant === 'step' && item.actions.length > 0) { item.actions.forEach(() => height++); } }); } if (state.failure) { height++; // generateFailureString returns 1 line } } } } } height++; // Blank line between files } return height; } /** * Calculate the height needed for the summary (now just line count) */ export function calculateSummaryHeight(testStates) { let height = 0; height++; // Status counts line const failuresExist = Object.values(testStates).some(state => !!state.failure); if (failuresExist) { height++; // "Failures:" title Object.values(testStates).forEach((state) => { if (state.failure) { height++; // Context line height++; // Failure message line (generateFailureString returns 1 line) height++; // Blank line after failure } }); } return height; } /** * Main function to redraw the UI */ export function redraw() { setRedrawScheduled(false); let testListLineCount = calculateTestListHeight(allRegisteredTests, currentTestStates); let summaryLineCount = calculateSummaryHeight(currentTestStates); if (Object.values(currentTestStates).length === 0) { // No tests, no summary summaryLineCount = 0; testListLineCount = 0; } const outputLines = []; // outputLines.push(''); // Initial blank line for spacing from prompt - REMOVED outputLines.push(...generateTitleBarString()); // generateTitleBarString now adds padding outputLines.push(UI_LEFT_PADDING); // Blank line after title bar with padding if (testListLineCount > 0) { outputLines.push(...generateTestListString()); // generateTestListString now adds padding // generateTestListString already adds a blank line (now with padding) at the end of each file section } if (summaryLineCount > 0) { if (testListLineCount > 0) outputLines.push(UI_LEFT_PADDING); // Blank line before summary if test list was also shown, with padding outputLines.push(...generateSummaryString()); // generateSummaryString now adds padding } const frameContent = outputLines.join('\n'); logUpdate.clear(); // Clear previous output before drawing new frame logUpdate(frameContent); setLastOutputLineCount(outputLines.length); // Still useful for potential future optimizations if (isFirstDraw) { // Still useful to track if it's the very first render pass setIsFirstDraw(false); } // If the rendering process has finished (stop() was called), // then this redraw is the final one, so call logUpdate.done(). if (isFinished) { logUpdate.done(); process.stderr.write('\n'); // Ensure prompt is on a new line after final output } } /** * Schedule a UI redraw if one is not already scheduled */ export function scheduleRedraw() { if (!redrawScheduled) { setRedrawScheduled(true); setImmediate(redraw); } }