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
14 changes: 12 additions & 2 deletions packages/magnitude-test/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { TermAppRenderer } from '@/term-app'; // Import TermAppRenderer
// Removed import { initializeUI, updateUI, cleanupUI } from '@/term-app';
import { startWebServers, stopWebServers } from './webServer';
import chalk from 'chalk';
import { renderHtmlReport } from './runner/htmlReport';

interface CliOptions {
workers?: number;
Expand Down Expand Up @@ -143,8 +144,9 @@ program
.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('--output-html <file>', 'write test results and agent history to an HTML file')
// Changed action signature from (filters, options) to (filter, options)
.action(async (filter, options: CliOptions) => {
.action(async (filter, options: CliOptions & { outputHtml?: string }) => {
dotenv.config();
let logLevel: string;

Expand Down Expand Up @@ -269,7 +271,15 @@ program
}

try {
const overallSuccess = await testSuiteRunner.runTests();
const { success: overallSuccess, results: testResults } = await testSuiteRunner.runTests();

// HTML report export
if (options.outputHtml) {
const html = renderHtmlReport(testResults);
require('fs').writeFileSync(options.outputHtml, html, 'utf-8');
console.log(`\nWrote HTML report to ${options.outputHtml}`);
}

process.exit(overallSuccess ? 0 : 1);
} catch (error) {
logger.error("Test suite execution failed:", error);
Expand Down
79 changes: 79 additions & 0 deletions packages/magnitude-test/src/runner/htmlReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// HTML report generation utility for Magnitude test results

interface HtmlReportTest {
test: any;
result: any;
}

export function renderHtmlReport(testResults: HtmlReportTest[]): string {
const summary = {
passed: testResults.filter(({ result }) => result.passed).length,
failed: testResults.filter(({ result }) => !result.passed).length,
total: testResults.length,
};
let html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Magnitude Test Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 2em; }
h1 { color: #2c3e50; }
.summary { margin-bottom: 2em; }
.passed { color: green; }
.failed { color: red; }
.test { border: 1px solid #ccc; border-radius: 6px; margin-bottom: 1.5em; padding: 1em; }
.test-title { font-weight: bold; font-size: 1.2em; }
.test-status { font-weight: bold; }
.test-error { color: #c0392b; margin-top: 0.5em; }
.test-section { margin-top: 0.5em; }
.step { margin-left: 1em; margin-bottom: 0.5em; }
.check { margin-left: 1em; margin-bottom: 0.5em; }
.action { margin-left: 2em; color: #555; }
</style>
</head>
<body>
<h1>Magnitude Test Report</h1>
<div class="summary">
<div><b>Total:</b> ${summary.total}</div>
<div class="passed"><b>Passed:</b> ${summary.passed}</div>
<div class="failed"><b>Failed:</b> ${summary.failed}</div>
</div>
<div class="tests">
`;
for (const { test, result } of testResults) {
html += `<div class="test">
<div class="test-title">${test.title}</div>
<div><b>URL:</b> ${test.url}</div>
<div class="test-status ${result.passed ? 'passed' : 'failed'}">${result.passed ? 'PASSED' : 'FAILED'}</div>
${!result.passed && result.failure ? `<div class="test-error">Error: ${result.failure.message}</div>` : ''}
`;
if (result.state && result.state.stepsAndChecks) {
html += `<div class="test-section"><b>Steps & Checks:</b></div>`;
for (const item of result.state.stepsAndChecks) {
if (item.variant === 'step') {
html += `<div class="step"><b>Step:</b> ${item.description} <span class="${item.status}">[${item.status}]</span></div>`;
if (item.thoughts && item.thoughts.length > 0) {
html += `<div class="action" style="color:#888;"><b>Thoughts:</b><ul>`;
for (const thought of item.thoughts) {
html += `<li>${thought}</li>`;
}
html += `</ul></div>`;
}
if (item.actions && item.actions.length > 0) {
for (const action of item.actions) {
html += `<div class="action">- ${action.pretty}</div>`;
}
}
} else if (item.variant === 'check') {
html += `<div class="check"><b>Check:</b> ${item.description} <span class="${item.status}">[${item.status}]</span></div>`;
}
}
} else {
html += `<div class="test-section"><i>No step/check data available.</i></div>`;
}
html += `</div>`;
}
html += ` </div>\n</body>\n</html>`;
return html;
}
24 changes: 18 additions & 6 deletions packages/magnitude-test/src/runner/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export interface StepDescriptor {
description: string,
actions: ActionDescriptor[],
//actions: Action[]
status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled'
status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled',
thoughts?: string[]
}

export interface CheckDescriptor {
Expand Down Expand Up @@ -52,12 +53,15 @@ export interface TestState {
failure?: TestFailure
}

export type TestResult = {
passed: true
} | {
passed: false
failure: TestFailure
export type TestResult = {
passed: true;
state?: TestState
}
| {
passed: false;
failure: TestFailure;
state?: TestState
};

export interface TestFailure {
message: string
Expand Down Expand Up @@ -106,6 +110,7 @@ export class TestStateTracker {
this.agent.checkEvents.on('checkStarted', this.onCheckStarted, this);
this.agent.checkEvents.on('checkDone', this.onCheckDone, this);

this.agent.events.on('thought', this.onThought, this);


// this.agent.events.on('action', this.onAction, this);
Expand Down Expand Up @@ -217,6 +222,13 @@ export class TestStateTracker {
this.events.emit('stateChanged', this.state);
}

onThought(thought: string) {
if (this.lastStepOrCheck && this.lastStepOrCheck.variant === 'step') {
if (!this.lastStepOrCheck.thoughts) this.lastStepOrCheck.thoughts = [];
this.lastStepOrCheck.thoughts.push(thought);
}
}

// onStepStart(description: string) {
// const stepDescriptor: StepDescriptor = {
// variant: 'step',
Expand Down
26 changes: 20 additions & 6 deletions packages/magnitude-test/src/runner/testSuiteRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class TestSuiteRunner {

private tests: RegisteredTest[];
private executors: Map<string, ClosedTestExecutor> = new Map();
private lastTestResults: Array<{ test: RegisteredTest, result: TestResult }> = [];

constructor(
config: TestSuiteRunnerConfig
Expand Down Expand Up @@ -87,7 +88,13 @@ export class TestSuiteRunner {
}
}

async runTests(): Promise<boolean> {
async runTests(): Promise<{
success: boolean,
results: Array<{
test: RegisteredTest,
result: TestResult
}>
}> {
if (!this.tests) throw new Error('No tests were registered');
this.renderer = this.runnerConfig.createRenderer(this.tests);
this.renderer.start?.();
Expand All @@ -111,17 +118,20 @@ export class TestSuiteRunner {
});

let overallSuccess = true;
let results: Array<{ test: RegisteredTest, result: TestResult }> = [];
try {
const poolResult: WorkerPoolResult<TestResult> = await workerPool.runTasks<TestResult>(
taskFunctions,
(taskOutcome: TestResult) => !taskOutcome.passed
);

for (const result of poolResult.results) {
for (let i = 0; i < poolResult.results.length; i++) {
const result = poolResult.results[i];
const test = this.tests[i];
if (result === undefined || !result.passed) {
overallSuccess = false;
break;
}
results.push({ test, result: result ?? { passed: false, failure: { message: 'No result' } } });
}
if (!poolResult.completed) { // If pool aborted for any reason (incl. a task failure)
overallSuccess = false;
Expand All @@ -130,9 +140,13 @@ export class TestSuiteRunner {
} catch (error) {
overallSuccess = false;
}
this.lastTestResults = results;
this.renderer.stop?.();
return overallSuccess;
return { success: overallSuccess, results };
}

public getLastTestResults() {
return this.lastTestResults;
}
}

Expand Down Expand Up @@ -176,7 +190,7 @@ const createNodeTestWorker: CreateTestWorker = async (workerData) =>
res(msg.result);
} else if (msg.type === "test_error") {
worker.off("message", messageHandler);
rej(new Error(msg.error));
res({ passed: false, failure: { message: msg.error } });
} else if (msg.type === "test_state_change") {
onStateChange(msg.state);
}
Expand Down Expand Up @@ -269,7 +283,7 @@ const createBunTestWorker: CreateTestWorker = async (workerData) =>
res(msg.result);
} else if (msg.type === "test_error") {
emit.off('message', messageHandler);
rej(new Error(msg.error));
res({ passed: false, failure: { message: msg.error } });
} else if (msg.type === "test_state_change") {
onStateChange(msg.state);
}
Expand Down
3 changes: 1 addition & 2 deletions packages/magnitude-test/src/worker/localTestRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,7 @@ messageEmitter.on('message', async (message: TestWorkerIncomingMessage) => {
postToParent({
type: 'test_result',
testId: test.id,
result: finalResult ??
{ passed: false, failure: { message: "Test result doesn't exist" } },
result: { ...finalResult, state: finalState }
});
} catch (error) {
postToParent({
Expand Down