Skip to content
Merged
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 .nx/version-plans/version-plan-1776413769245.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: patch
---

Using expect.soft will no longer throw an internal exception.
101 changes: 101 additions & 0 deletions packages/bundler-metro/src/__tests__/withRnHarness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';

type MinimalMetroConfig = {
projectRoot: string;
serializer?: {
isThirdPartyModule?: (module: { path: string }) => boolean;
};
symbolicator?: {
customizeFrame?: (frame: { file?: string | null }) => Promise<{
collapse: boolean;
}>;
};
};

vi.mock('@react-native-harness/config', () => ({
getConfig: vi.fn(async () => ({
config: {},
})),
}));

vi.mock('../babel-transformer.js', () => ({
getHarnessBabelTransformerPath: vi.fn(
() => '/tmp/harness-babel-transformer.js',
),
}));

vi.mock('../manifest.js', () => ({
getHarnessManifest: vi.fn(() => '/tmp/harness-manifest.js'),
}));

vi.mock('../metro-cache.js', () => ({
getHarnessCacheStores: vi.fn(() => []),
}));

vi.mock('../resolvers/resolver.js', () => ({
getHarnessResolver: vi.fn(() => vi.fn()),
}));

describe('withRnHarness', () => {
it('treats installed Harness packages as internal callsites', async () => {
const { withRnHarness } = await import('../withRnHarness.js');

const config = (await withRnHarness(
{
projectRoot: '/tmp/app',
serializer: {},
symbolicator: {
async customizeFrame() {
return {};
},
},
},
true,
)()) as unknown as MinimalMetroConfig;

expect(
config.serializer?.isThirdPartyModule?.({
path: '/repo/node_modules/@react-native-harness/runtime/dist/expect/errors.js',
}),
).toBe(true);

await expect(
config.symbolicator?.customizeFrame?.({
file: '/repo/node_modules/@react-native-harness/runtime/dist/expect/errors.js',
}),
).resolves.toEqual({
collapse: true,
});
});

it('does not collapse app source files', async () => {
const { withRnHarness } = await import('../withRnHarness.js');

const config = (await withRnHarness(
{
projectRoot: '/tmp/app',
serializer: {},
symbolicator: {
async customizeFrame() {
return {};
},
},
},
true,
)()) as unknown as MinimalMetroConfig;

expect(
config.serializer?.isThirdPartyModule?.({
path: '/repo/apps/playground/src/__tests__/smoke.harness.ts',
}),
).toBe(false);

await expect(
config.symbolicator?.customizeFrame?.({
file: '/repo/apps/playground/src/__tests__/smoke.harness.ts',
}),
).resolves.toEqual({
collapse: false,
});
});
});
6 changes: 2 additions & 4 deletions packages/bundler-metro/src/withRnHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const INTERNAL_CALLSITES_REGEX =

export const withRnHarness = <T extends MetroConfig>(
config: T | Promise<T>,
isInvokedByHarness = false
isInvokedByHarness = false,
): (() => Promise<T>) => {
return async () => {
if (!isInvokedByHarness) {
Expand All @@ -42,9 +42,7 @@ export const withRnHarness = <T extends MetroConfig>(
getPolyfills: (...args) => [
...(metroConfig.serializer?.getPolyfills?.(...args) ?? []),
harnessManifest,
require.resolve(
'@react-native-harness/runtime/polyfills/harness-module-system'
),
require.resolve('@react-native-harness/runtime/polyfills/harness-module-system'),
],
isThirdPartyModule({ path: modulePath }) {
const isThirdPartyByDefault =
Expand Down
30 changes: 30 additions & 0 deletions packages/runtime/src/expect/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
type HarnessExpectError = {
name?: string;
message?: string;
stack?: string;
};

export type HarnessExpectTestState = {
result?: {
state: 'pass' | 'fail';
errors?: HarnessExpectError[];
};
promises?: Promise<unknown>[];
onFinished?: Array<() => void | Promise<void>>;
};

declare global {
var HARNESS_EXPECT_TEST_STATE: HarnessExpectTestState | undefined;
}

export const getCurrentExpectTestState = ():
| HarnessExpectTestState
| undefined => {
return globalThis.HARNESS_EXPECT_TEST_STATE;
};

export const setCurrentExpectTestState = (
state: HarnessExpectTestState | undefined,
): void => {
globalThis.HARNESS_EXPECT_TEST_STATE = state;
};
76 changes: 76 additions & 0 deletions packages/runtime/src/expect/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { HarnessExpectTestState } from './context.js';

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

const formatErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return `${error.name}: ${error.message}`;
}

if (error && typeof error === 'object') {
const maybeError = error as { name?: string; message?: string };
if (maybeError.name || maybeError.message) {
return [maybeError.name, maybeError.message].filter(Boolean).join(': ');
}
}

return String(error);
};

const createExpectError = (errors: unknown[], title?: string): Error => {
const message = [title, ...errors.map(formatErrorMessage)]
.filter(Boolean)
.join('\n\n');

const error = new Error(message);
const firstError = errors.find(
(value): value is SerializedExpectError =>
!!value && typeof value === 'object',
);

if (firstError?.name) {
error.name = firstError.name;
}

if (firstError?.stack) {
error.stack = firstError.stack.replace(
/^([^\n]+)(\n|$)/,
`${error.name}: ${message}$2`,
);
}

return error;
};

export const flushExpectTestState = async (
state: HarnessExpectTestState,
): Promise<void> => {
if (state.promises?.length) {
const results = await Promise.allSettled(state.promises);
const rejected = results
.filter(
(result): result is PromiseRejectedResult =>
result.status === 'rejected',
)
.map((result) => result.reason);

if (rejected.length > 0) {
throw createExpectError(rejected);
}
}

for (const hook of state.onFinished ?? []) {
await hook();
}

const softErrors = state.result?.errors ?? [];
if (softErrors.length === 0) {
return;
}

throw createExpectError(softErrors, 'Soft assertion failures:');
};
21 changes: 16 additions & 5 deletions packages/runtime/src/expect/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,30 @@ import {
import * as chai from 'chai';

// Setup additional matchers
import { getCurrentExpectTestState } from './context.js';
import './setup.js';
import { toMatchImageSnapshot } from './matchers/toMatchImageSnapshot.js';

export function createExpect(): ExpectStatic {
const expect = ((value: unknown, message?: string): Assertion => {
const { assertionCalls } = getState(expect);
setState({ assertionCalls: assertionCalls + 1 }, expect);
return chai.expect(value, message) as unknown as Assertion;

const assertion = chai.expect(value, message) as unknown as Assertion & {
withTest?: (test: unknown) => Assertion;
};
const currentTest = getCurrentExpectTestState();

return currentTest && assertion.withTest
? assertion.withTest(currentTest)
: assertion;
}) as ExpectStatic;
Object.assign(expect, chai.expect);
Object.assign(
expect,
globalThis[ASYMMETRIC_MATCHERS_OBJECT as unknown as keyof typeof globalThis]
globalThis[
ASYMMETRIC_MATCHERS_OBJECT as unknown as keyof typeof globalThis
],
);

expect.getState = () => getState<MatcherState>(expect);
Expand All @@ -44,7 +55,7 @@ export function createExpect(): ExpectStatic {
expectedAssertionsNumber: null,
expectedAssertionsNumberErrorGen: null,
},
expect
expect,
);

// @ts-expect-error untyped
Expand All @@ -62,7 +73,7 @@ export function createExpect(): ExpectStatic {
// @ts-expect-error untyped
expect.unreachable = (message?: string) => {
chai.assert.fail(
`expected${message ? ` "${message}" ` : ' '}not to be reached`
`expected${message ? ` "${message}" ` : ' '}not to be reached`,
);
};

Expand All @@ -71,7 +82,7 @@ export function createExpect(): ExpectStatic {
new Error(
`expected number of assertions to be ${expected}, but got ${
expect.getState().assertionCalls
}`
}`,
);
if (Error.captureStackTrace) {
Error.captureStackTrace(errorGen(), assertions);
Expand Down
36 changes: 25 additions & 11 deletions packages/runtime/src/runner/runSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import type {
TestSuite,
TestSuiteResult,
} from '@react-native-harness/bridge';
import {
setCurrentExpectTestState,
type HarnessExpectTestState,
} from '../expect/context.js';
import { flushExpectTestState } from '../expect/errors.js';
import { runHooks } from './hooks.js';
import { getTestExecutionError } from './errors.js';
import { TestRunnerContext } from './types.js';
Expand All @@ -15,7 +20,7 @@ declare global {
const runTest = async (
test: TestCase,
suite: TestSuite,
context: TestRunnerContext
context: TestRunnerContext,
): Promise<TestResult> => {
const startTime = Date.now();

Expand Down Expand Up @@ -69,14 +74,23 @@ const runTest = async (
return result;
}

// Run all beforeEach hooks from the current suite and its parents
await runHooks(suite, 'beforeEach');
const expectTestState: HarnessExpectTestState = {};
setCurrentExpectTestState(expectTestState);

// Run the actual test
await test.fn();
try {
// Run all beforeEach hooks from the current suite and its parents
await runHooks(suite, 'beforeEach');

// Run all afterEach hooks from the current suite and its parents
await runHooks(suite, 'afterEach');
// Run the actual test
await test.fn();

// Run all afterEach hooks from the current suite and its parents
await runHooks(suite, 'afterEach');

await flushExpectTestState(expectTestState);
} finally {
setCurrentExpectTestState(undefined);
}

const duration = Date.now() - startTime;

Expand All @@ -102,7 +116,7 @@ const runTest = async (
error,
context.testFilePath,
suite.name,
test.name
test.name,
);
const duration = Date.now() - startTime;

Expand Down Expand Up @@ -130,7 +144,7 @@ const runTest = async (

export const runSuite = async (
suite: TestSuite,
context: TestRunnerContext
context: TestRunnerContext,
): Promise<TestSuiteResult> => {
const startTime = Date.now();

Expand Down Expand Up @@ -212,10 +226,10 @@ export const runSuite = async (

// Check if any tests or child suites failed
const hasFailedTests = testResults.some(
(result) => result.status === 'failed'
(result) => result.status === 'failed',
);
const hasFailedSuites = suiteResults.some(
(result) => result.status === 'failed'
(result) => result.status === 'failed',
);

if (hasFailedTests || hasFailedSuites) {
Expand Down
Loading
Loading