diff --git a/src/testRunner.ts b/src/testRunner.ts index 304c703..b346e0c 100644 --- a/src/testRunner.ts +++ b/src/testRunner.ts @@ -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. * @@ -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); } } } @@ -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 to equal specified value: " + * - "Expected to include " + * - "Expected to contain " + * 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"); @@ -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 }; } diff --git a/test/testRunner.test.ts b/test/testRunner.test.ts new file mode 100644 index 0000000..407dd60 --- /dev/null +++ b/test/testRunner.test.ts @@ -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'); + }); + }); +});