Skip to content
Draft
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
94 changes: 87 additions & 7 deletions src/testRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ export interface TestResult {
output?: string;
}

/**
* Parsed failure information from lab output.
*
* @property message - The full error message
* @property actualOutput - The actual output value (for diff view)
* @property expectedOutput - The expected output value (for diff view)
*/
interface FailureInfo {
message: string;
actualOutput?: string;
expectedOutput?: string;
}

/**
* Executes a single @hapi/lab test case or describe block.
*
Expand Down Expand Up @@ -204,19 +217,40 @@ export async function runLabTest(
}
}
} else {
const message =
parseErrorMessage(output + errorOutput) || "Test failed";
run.failed(testItem, new vscode.TestMessage(message), duration);
const failureInfo =
parseErrorMessage(output + errorOutput) || { message: "Test failed" };

const testMessage = new vscode.TestMessage(failureInfo.message);

// If we have actual/expected values, set them to enable VS Code's diff view
if (failureInfo.actualOutput && failureInfo.expectedOutput) {
testMessage.actualOutput = failureInfo.actualOutput;
testMessage.expectedOutput = failureInfo.expectedOutput;
}

run.failed(testItem, testMessage, duration);

// Mark failed descendants detected in real-time with actual error message
for (const [descendant, descendantDuration] of failedDescendants) {
markedDescendants.add(descendant);
run.failed(descendant, new vscode.TestMessage(message), descendantDuration);
const descendantMessage = new vscode.TestMessage(failureInfo.message);
if (failureInfo.actualOutput && failureInfo.expectedOutput) {
descendantMessage.actualOutput = failureInfo.actualOutput;
descendantMessage.expectedOutput = failureInfo.expectedOutput;
}
run.failed(descendant, descendantMessage, descendantDuration);
}

// Mark remaining descendants as failed
if (descendants) {
for (const descendant of descendants) {
if (!markedDescendants.has(descendant)) {
run.failed(descendant, new vscode.TestMessage(message), duration);
const descendantMessage = new vscode.TestMessage(failureInfo.message);
if (failureInfo.actualOutput && failureInfo.expectedOutput) {
descendantMessage.actualOutput = failureInfo.actualOutput;
descendantMessage.expectedOutput = failureInfo.expectedOutput;
}
run.failed(descendant, descendantMessage, duration);
}
}
}
Expand All @@ -240,7 +274,24 @@ export async function runLabTest(
*/
const FAILURE_START_PATTERN = /^\s*\d+\)\s+.+:/;

function parseErrorMessage(output: string): string | undefined {
/**
* Pattern to extract actual and expected values from lab's error messages.
* Matches:
* - "Expected <actual> to equal specified value: <expected>"
* - "Expected <actual> to include <expected>"
* - "Expected <actual> to contain <expected>"
* Captures: [1] actual value, [2] expected value
*/
const EXPECTED_PATTERN = /Expected (.+?) to (?:equal specified value:|include|contain)\s*(.+?)(?:\s*at\s|$)/s;

/**
* Parses lab's failure output to extract error message and actual/expected values.
* Exported for testing purposes.
*
* @param output - Raw test output from lab
* @returns Parsed failure information with message and optional actual/expected values
*/
export function parseErrorMessage(output: string): FailureInfo | undefined {
// Strip ANSI escape codes for cleaner parsing
const cleanOutput = output.replace(ANSI_ESCAPE_PATTERN, "");
const lines = cleanOutput.split("\n");
Expand Down Expand Up @@ -272,5 +323,34 @@ function parseErrorMessage(output: string): string | undefined {
errorLines.pop();
}

return errorLines.length > 0 ? errorLines.join("\n") : undefined;
if (errorLines.length === 0) {
return undefined;
}

const message = errorLines.join("\n");

// Try to extract actual and expected values for assertion failures
const match = EXPECTED_PATTERN.exec(message);
if (match) {
const [, actualStr, expectedStr] = match;

// Clean up the values by removing surrounding quotes (if both start and end match) and trimming
const cleanValue = (val: string): string => {
const trimmed = val.trim();
// Remove quotes only if they match at both ends
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1);
}
return trimmed;
};

return {
message,
actualOutput: cleanValue(actualStr),
expectedOutput: cleanValue(expectedStr),
};
}

return { message };
}
217 changes: 217 additions & 0 deletions test/testRunner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, it, expect } from 'vitest';
import { parseErrorMessage } from '../src/testRunner';

describe('testRunner', () => {
describe('parseErrorMessage', () => {
it('should parse simple string comparison failure', () => {
const output = `
Failed tests:

1) Example test should fail with actual/expected mismatch:

actual expected

'aababa'

Expected 'aaba' to equal specified value: 'ba'

at /tmp/lab-test-example/test.js:11:27
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain("Expected 'aaba' to equal specified value: 'ba'");
expect(result?.actualOutput).toBe('aaba');
expect(result?.expectedOutput).toBe('ba');
});

it('should parse include assertion failure', () => {
const output = `
Failed tests:

1) Various assertion types should fail with include assertion:

Expected { a: 1 } to include { b: 1 }

at /tmp/lab-test-include/test.js:9:29
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain('Expected { a: 1 } to include { b: 1 }');
expect(result?.actualOutput).toBe('{ a: 1 }');
expect(result?.expectedOutput).toBe('{ b: 1 }');
});

it('should parse contain assertion failure', () => {
const output = `
Failed tests:

1) Various assertion types should fail with contain assertion:

Expected 'hello' to include 'world'

at /tmp/lab-test-include/test.js:13:28
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain("Expected 'hello' to include 'world'");
expect(result?.actualOutput).toBe('hello');
expect(result?.expectedOutput).toBe('world');
});

it('should parse number comparison failure', () => {
const output = `
Failed tests:

1) Example test should fail with number mismatch:

actual expected

42100

Expected 42 to equal specified value: 100

at /tmp/lab-test-example/test.js:15:23
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain('Expected 42 to equal specified value: 100');
expect(result?.actualOutput).toBe('42');
expect(result?.expectedOutput).toBe('100');
});

it('should parse object comparison failure', () => {
const output = `
Failed tests:

1) Example test should fail with object mismatch:

actual expected

{
baz: 123456,
foo: 'bar'
}

Expected { foo: 'bar', baz: 123 } to equal specified value: { foo: 'bar', baz: 456 }

at /tmp/lab-test-example/test.js:19:45
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain("Expected { foo: 'bar', baz: 123 } to equal specified value: { foo: 'bar', baz: 456 }");
expect(result?.actualOutput).toBe("{ foo: 'bar', baz: 123 }");
expect(result?.expectedOutput).toBe("{ foo: 'bar', baz: 456 }");
});

it('should handle errors without expected values', () => {
const output = `
Failed tests:

1) Example test should throw error:

Error: Something went wrong

at /tmp/lab-test-example/test.js:23:15
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain('Error: Something went wrong');
expect(result?.actualOutput).toBeUndefined();
expect(result?.expectedOutput).toBeUndefined();
});

it('should handle generic AssertionError', () => {
const output = `
Failed tests:

1) Example test should fail:

AssertionError: expected value to be truthy

at /tmp/lab-test-example/test.js:27:10
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain('AssertionError: expected value to be truthy');
expect(result?.actualOutput).toBeUndefined();
expect(result?.expectedOutput).toBeUndefined();
});

it('should strip ANSI escape codes', () => {
// ANSI codes for red and reset
const output = `
Failed tests:

1) Example test:

\u001b[31mExpected 'foo' to equal specified value: 'bar'\u001b[0m

at /tmp/test.js:10:20
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.message).toContain("Expected 'foo' to equal specified value: 'bar'");
expect(result?.message).not.toContain('\u001b[31m');
expect(result?.message).not.toContain('\u001b[0m');
expect(result?.actualOutput).toBe('foo');
expect(result?.expectedOutput).toBe('bar');
});

it('should return undefined for empty output', () => {
const result = parseErrorMessage('');
expect(result).toBeUndefined();
});

it('should return undefined for output without errors', () => {
const output = `
Example test
✓ 1) should pass (10 ms)

1 of 1 tests passed
`;

const result = parseErrorMessage(output);
expect(result).toBeUndefined();
});

it('should handle multiline actual/expected values', () => {
const output = `
Failed tests:

1) Example test:

Expected {
foo: 'bar',
nested: { a: 1 }
} to equal specified value: {
foo: 'baz',
nested: { a: 2 }
}

at /tmp/test.js:10:20
`;

const result = parseErrorMessage(output);

expect(result).toBeDefined();
expect(result?.actualOutput).toContain('foo');
expect(result?.expectedOutput).toContain('baz');
});
});
});
Loading