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
8 changes: 8 additions & 0 deletions .nx/version-plans/version-plan-1792067894893.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
__default__: minor
---

Failed assertions now print a `- Expected` / `+ Received` diff below the error
message, the same way Vitest shows them. This works for regular assertions and
for every failed `expect.soft` call. Plugins can read the diff from the new
optional `diff` field on test errors.
2 changes: 2 additions & 0 deletions packages/bridge/src/shared/test-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type TestResultStatus = 'passed' | 'failed' | 'skipped' | 'todo';
export type SerializedError = {
name: string;
message: string;
// Expected/received diff of a failed assertion, formatted like Vitest's.
diff?: string;
codeFrame?: CodeFrame;
diagnostics?: {
pendingPromises?: {
Expand Down
20 changes: 20 additions & 0 deletions packages/jest/src/__tests__/format-harness-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,24 @@ describe('formatHarnessErrorMessage', () => {
expect(message).toContain('Promise #7, created 10ms after test start:');
expect(message).toContain(' Error: Promise created');
});

it('prints the assertion diff between the message and the code frame', () => {
const message = formatHarnessErrorMessage({
name: 'AssertionError',
message: 'expected 1 to be 2 // Object.is equality',
diff: '- Expected\n+ Received\n\n- 2\n+ 1',
codeFrame: {
content: '> 3 | expect(1).toBe(2);',
fileName: 'example.harness.ts',
},
});

expect(message).toBe(
[
'expected 1 to be 2 // Object.is equality',
'- Expected\n+ Received\n\n- 2\n+ 1',
'> 3 | expect(1).toBe(2);',
].join('\n\n'),
);
});
});
5 changes: 5 additions & 0 deletions packages/jest/src/format-harness-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export const formatHarnessErrorMessage = (
}

const parts = [error.message];

if (error.diff) {
parts.push(error.diff);
}

const pendingPromiseDetails = error.diagnostics?.pendingPromises
? formatPendingPromises(
error.diagnostics.pendingPromises,
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@react-native-harness/bridge": "workspace:*",
"@vitest/expect": "4.0.16",
"@vitest/spy": "4.0.16",
"@vitest/utils": "4.0.16",
"chai": "^6.2.2",
"event-target-shim": "^6.0.2",
"react-native-url-polyfill": "^3.0.0",
Expand Down
111 changes: 111 additions & 0 deletions packages/runtime/src/__tests__/assertion-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { stripVTControlCharacters } from 'node:util';
import type { SerializedError } from '@react-native-harness/bridge';
import { describe, expect, it, vi } from 'vitest';
import {
describe as harnessDescribe,
getTestCollector,
it as harnessIt,
} from '../collector/index.js';
import { expect as harnessExpect } from '../expect/index.js';
import { getTestRunner } from '../runner/index.js';

vi.mock('../symbolicate.js', async () => {
return {
getCodeFrame: vi.fn(async () => null),
};
});

const runFailingTest = async (
fn: () => void,
): Promise<{ result?: SerializedError; event?: SerializedError }> => {
const collector = getTestCollector();
const runner = getTestRunner();
let event: SerializedError | undefined;

runner.events.addListener((runnerEvent) => {
if (runnerEvent.type === 'test-finished') {
event = runnerEvent.error;
}
});

try {
const collection = await collector.collect(() => {
harnessDescribe('Diff Suite', () => {
harnessIt('fails', fn);
});
}, 'runtime/assertion-diff.test.ts');

const result = await runner.run({
testSuite: collection.testSuite,
testFilePath: 'runtime/assertion-diff.test.ts',
runner: 'ios',
});

expect(result.suites[0].tests[0].status).toBe('failed');

return { result: result.suites[0].tests[0].error, event };
} finally {
collector.dispose();
runner.dispose();
}
};

const strip = (value: string | undefined) =>
value === undefined ? undefined : stripVTControlCharacters(value);

// expect.soft is installed at runtime but missing from @vitest/expect's types.
const softExpect = (harnessExpect as unknown as { soft: typeof harnessExpect })
.soft;

describe('assertion diffs', () => {
it('serializes the expected/received diff of a failed assertion', async () => {
const { result, event } = await runFailingTest(() => {
harnessExpect({ name: 'Nitro', year: 2018 }).toEqual({
name: 'Nitro',
year: 2019,
});
});

expect(result?.message).toContain('to deeply equal');
expect(strip(result?.diff)).toBe(
[
'- Expected',
'+ Received',
'',
' {',
' "name": "Nitro",',
'- "year": 2019,',
'+ "year": 2018,',
' }',
].join('\n'),
);
expect(event?.diff).toBe(result?.diff);
});

it('does not add a diff to errors without expected and actual values', async () => {
const { result } = await runFailingTest(() => {
throw new Error('plain failure');
});

expect(result?.message).toBe('plain failure');
expect(result?.diff).toBeUndefined();
});

it('keeps the diff of every failed soft assertion', async () => {
const { result } = await runFailingTest(() => {
softExpect(1).toBe(2);
softExpect('a').toBe('b');
});

expect(strip(result?.message)).toBe(
[
'Soft assertion failures:',
'AssertionError: expected 1 to be 2 // Object.is equality',
'- Expected\n+ Received\n\n- 2\n+ 1',
"AssertionError: expected 'a' to be 'b' // Object.is equality",
'Expected: "b"\nReceived: "a"',
].join('\n\n'),
);
expect(result?.diff).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions packages/runtime/src/expect/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { processError } from '@vitest/utils/error';

const readDiff = (error: { diff?: unknown }): string | undefined =>
typeof error.diff === 'string' && error.diff ? error.diff : undefined;

// Vitest computes the expected/received diff in its test runner, so
// @vitest/expect only does it for soft assertions. Thrown assertion errors
// get the same treatment here.
export const getErrorDiff = (error: unknown): string | undefined => {
if (!error || typeof error !== 'object') {
return undefined;
}

// Already processed, e.g. a soft assertion error.
if ('diff' in error) {
return readDiff(error);
}

try {
return readDiff(processError(error));
} catch {
return undefined;
}
};
12 changes: 11 additions & 1 deletion packages/runtime/src/expect/errors.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { HarnessExpectTestState } from './context.js';
import { getErrorDiff } from './diff.js';

type SerializedExpectError = {
name?: string;
message?: string;
stack?: string;
};

const formatErrorMessage = (error: unknown): string => {
const formatErrorTitle = (error: unknown): string => {
if (error instanceof Error) {
return `${error.name}: ${error.message}`;
}
Expand All @@ -21,6 +22,15 @@ const formatErrorMessage = (error: unknown): string => {
return String(error);
};

// The flattened error has no actual/expected of its own, so each failure
// keeps its diff next to its message.
const formatErrorMessage = (error: unknown): string => {
const title = formatErrorTitle(error);
const diff = getErrorDiff(error);

return diff ? `${title}\n\n${diff}` : title;
};

const createExpectError = (errors: unknown[], title?: string): Error => {
const message = [title, ...errors.map(formatErrorMessage)]
.filter(Boolean)
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/src/runner/errors.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { SerializedError, CodeFrame } from '@react-native-harness/bridge';
import { getErrorDiff } from '../expect/diff.js';
import { getCodeFrame } from '../symbolicate.js';

export class TestExecutionError extends Error {
file: string;
suite: string;
test: string;
codeFrame?: CodeFrame;
diff?: string;

constructor(
error: unknown,
Expand All @@ -21,6 +23,7 @@ export class TestExecutionError extends Error {
this.test = test;
this.cause = error;
this.codeFrame = codeFrame;
this.diff = getErrorDiff(error);
}

toSerializedJSON(): SerializedError {
Expand All @@ -39,6 +42,7 @@ export class TestExecutionError extends Error {
return {
name: causeName,
message: causeMessage,
diff: this.diff,
codeFrame: causeCodeFrame,
diagnostics: causeDiagnostics,
};
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading