Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/neat-birds-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"magnitude-test": patch
---

beforeAll, beforeEach, afterEach, afterAll hooks may now be registered within groups to scope them only to tests within the group. For the time being, afterAll hooks regardless of group will only run after all tests in a module are complete. This may change in the future.
6 changes: 4 additions & 2 deletions packages/magnitude-test/src/discovery/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ export type TestGroupFunction = () => void;
export interface TestGroup {
name: string;
options?: TestOptions;
id?: string;
}

export interface TestGroupDeclaration {
(id: string, options: TestOptions, groupFn: TestGroupFunction): void;
(id: string, groupFn: TestGroupFunction): void;
(name: string, options: TestOptions, groupFn: TestGroupFunction): void;
(name: string, groupFn: TestGroupFunction): void;
}

export interface TestDeclaration {
Expand All @@ -86,4 +87,5 @@ export interface RegisteredTest {
// meta
filepath: string,
group?: string,
groupHierarchy?: Array<{ name: string; id?: string }>,
}
210 changes: 116 additions & 94 deletions packages/magnitude-test/src/term-app/uiRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,67 +130,96 @@ export function generateTestString(test: RegisteredTest, state: RunnerTestState,
return output;
}

// Helper function to group tests for display
function groupRegisteredTestsForDisplay(tests: RegisteredTest[]):
Record<string, { ungrouped: RegisteredTest[], groups: Record<string, RegisteredTest[]> }> {
const files: Record<string, { ungrouped: RegisteredTest[], groups: Record<string, RegisteredTest[]> }> = {};
// Tree node structure for hierarchical display
interface TreeNode {
tests: RegisteredTest[];
children: Record<string, TreeNode>;
}

// Helper function to build a tree structure from hierarchical tests
function buildTestTree(tests: RegisteredTest[]): TreeNode {
const root: TreeNode = { tests: [], children: {} };

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);
if (!test.groupHierarchy || test.groupHierarchy.length === 0) {
// Tests without hierarchy go in the root
root.tests.push(test);
} else {
files[test.filepath].ungrouped.push(test);
// Build the tree path for hierarchical tests
let currentNode = root;
for (const group of test.groupHierarchy) {
if (!currentNode.children[group.name]) {
currentNode.children[group.name] = { tests: [], children: {} };
}
currentNode = currentNode.children[group.name];
}
currentNode.tests.push(test);
}
}
return files;

return root;
}

// Helper function to group tests by file and build trees
function groupTestsByFile(tests: RegisteredTest[]): Record<string, TreeNode> {
const files: Record<string, RegisteredTest[]> = {};

// Group tests by file first
for (const test of tests) {
if (!files[test.filepath]) {
files[test.filepath] = [];
}
files[test.filepath].push(test);
}

// Build tree for each file
const fileTrees: Record<string, TreeNode> = {};
for (const [filepath, fileTests] of Object.entries(files)) {
fileTrees[filepath] = buildTestTree(fileTests);
}

return fileTrees;
}


// Helper function to recursively generate tree display
function generateTreeDisplay(node: TreeNode, indent: number, output: string[]): void {
// Display tests at this level
for (const test of node.tests) {
const state = currentTestStates[test.id];
if (state) {
const testLines = generateTestString(test, state, indent);
output.push(...testLines);
}
}

// Display child groups
for (const [groupName, childNode] of Object.entries(node.children)) {
const groupHeader = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}↳ ${groupName}${ANSI_RESET}`;
output.push(UI_LEFT_PADDING + ' '.repeat(indent) + groupHeader);

// Recursively display child node with increased indent
generateTreeDisplay(childNode, indent + 2, output);
}
}

/**
* Generate the test list portion of the UI
*/
export function generateTestListString(): string[] {
const output: string[] = [];
const fileIndent = 0;
const groupIndent = fileIndent + 2;
const testBaseIndent = groupIndent;

const groupedDisplayTests = groupRegisteredTestsForDisplay(allRegisteredTests);
const fileTrees = groupTestsByFile(allRegisteredTests);

for (const [filepath, { ungrouped, groups }] of Object.entries(groupedDisplayTests)) {
for (const [filepath, treeRoot] of Object.entries(fileTrees)) {
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);
// Generate tree display starting from root
generateTreeDisplay(treeRoot, fileIndent + 2, output);

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
output.push(UI_LEFT_PADDING); // Blank line between files
}
return output;
}
Expand All @@ -204,7 +233,7 @@ export function generateSummaryString(): string[] {
let totalOutputTokens = 0;
const statusCounts = { pending: 0, running: 0, passed: 0, failed: 0, cancelled: 0, total: 0 };
const failuresWithContext: { filepath: string; groupName?: string; testTitle: string; failure: TestFailure }[] = [];

const testContextMap = new Map<string, { filepath: string; groupName?: string; testTitle: string }>();
allRegisteredTests.forEach(test => {
testContextMap.set(test.id, { filepath: test.filepath, groupName: test.group, testTitle: test.title });
Expand Down Expand Up @@ -241,7 +270,7 @@ export function generateSummaryString(): string[] {
costDescription = ` (\$${cost.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) {
Expand All @@ -257,60 +286,53 @@ export function generateSummaryString(): string[] {
return output;
}

// Helper function to calculate tree height recursively
function calculateTreeHeight(node: TreeNode, testStates: AllTestStates, indent: number): number {
let height = 0;

// Count tests at this level
for (const test of node.tests) {
const state = testStates[test.id];
if (state) {
height++; // Test title line
if (state.stepsAndChecks) {
state.stepsAndChecks.forEach((item: RunnerStepDescriptor | RunnerCheckDescriptor) => {
height++; // Item description line
if (item.variant === 'step') {
if (renderSettings.showActions) {
height += item.actions?.length ?? 0;
}
if (renderSettings.showThoughts) {
height += item.thoughts?.length ?? 0;
}
}
});
}
if (state.failure) {
height++; // generateFailureString returns 1 line
}
}
}

// Count child groups recursively
for (const [groupName, childNode] of Object.entries(node.children)) {
height++; // Group header line
height += calculateTreeHeight(childNode, testStates, indent + 2);
}

return height;
}

/**
* Calculate the height needed for the test list (now just line count)
*/
export function calculateTestListHeight(tests: RegisteredTest[], testStates: AllTestStates): number {
let height = 0;
const groupedDisplayTests = groupRegisteredTestsForDisplay(tests);

const addStepsAndChecksHeight = (state: RunnerTestState) => {
if (state.stepsAndChecks) {
state.stepsAndChecks.forEach((item: RunnerStepDescriptor | RunnerCheckDescriptor) => {
height++; // Item description line
if (item.variant === 'step') {
if (renderSettings.showActions) {
height += item.actions?.length ?? 0;
}
if (renderSettings.showThoughts) {
height += item.thoughts?.length ?? 0;
}
}
});
}
};
const fileTrees = groupTestsByFile(tests);

for (const [filepath, { ungrouped, groups }] of Object.entries(groupedDisplayTests)) {
for (const [filepath, treeRoot] of Object.entries(fileTrees)) {
height++; // File header line

if (ungrouped.length > 0) {
for (const test of ungrouped) {
const state = testStates[test.id];
if (state) {
height++; // Test title line
addStepsAndChecksHeight(state);
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
addStepsAndChecksHeight(state);
if (state.failure) {
height++; // generateFailureString returns 1 line
}
}
}
}
}
height += calculateTreeHeight(treeRoot, testStates, 2); // Start with indent of 2
height++; // Blank line between files
}
return height;
Expand Down Expand Up @@ -347,9 +369,9 @@ export function redraw() {
let summaryLineCount = calculateSummaryHeight(currentTestStates);
if (Object.values(currentTestStates).length === 0) { // No tests, no summary
summaryLineCount = 0;
testListLineCount = 0;
testListLineCount = 0;
}

const outputLines: string[] = [];
// outputLines.push(''); // Initial blank line for spacing from prompt - REMOVED

Expand All @@ -367,7 +389,7 @@ export function redraw() {
}

const frameContent = outputLines.join('\n');

logUpdate.clear(); // Clear previous output before drawing new frame
logUpdate(frameContent);

Expand Down
Loading