From 3ffcad23a33f20e7696a3db450d682ded9c9c59f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 19 May 2026 19:28:35 +0200 Subject: [PATCH 1/9] feat(runtime): add task context to runtime tests --- packages/bridge/src/shared.ts | 6 + packages/bridge/src/shared/test-collector.ts | 10 +- packages/bridge/src/shared/test-context.ts | 15 ++ .../src/__tests__/runner-context.test.ts | 132 ++++++++++++++++++ packages/runtime/src/collector/functions.ts | 9 +- packages/runtime/src/collector/types.ts | 5 +- packages/runtime/src/collector/validation.ts | 4 +- packages/runtime/src/runner/hooks.ts | 12 +- packages/runtime/src/runner/runSuite.ts | 26 +++- packages/runtime/src/runner/types.ts | 3 + 10 files changed, 203 insertions(+), 19 deletions(-) create mode 100644 packages/bridge/src/shared/test-context.ts create mode 100644 packages/runtime/src/__tests__/runner-context.test.ts diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index d8196db8..709d4843 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -81,7 +81,13 @@ export type { TestSuite, TestCase, CollectionResult, + TestFn, + SuiteHookFn, } from './shared/test-collector.js'; +export type { + HarnessTaskContext, + HarnessTestContext, +} from './shared/test-context.js'; export type { TestRunnerEvents, TestRunnerFileStartedEvent, diff --git a/packages/bridge/src/shared/test-collector.ts b/packages/bridge/src/shared/test-collector.ts index 24126b5c..040f0c93 100644 --- a/packages/bridge/src/shared/test-collector.ts +++ b/packages/bridge/src/shared/test-collector.ts @@ -1,6 +1,10 @@ +import type { HarnessTestContext } from './test-context.js'; + export type TestStatus = 'active' | 'skipped' | 'todo'; -export type TestFn = () => void | Promise; +export type TestFn = (context?: HarnessTestContext) => void | Promise; + +export type SuiteHookFn = () => void | Promise; export type TestCase = { name: string; @@ -13,8 +17,8 @@ export type TestSuite = { tests: TestCase[]; suites: TestSuite[]; parent?: TestSuite; - beforeAll: TestFn[]; - afterAll: TestFn[]; + beforeAll: SuiteHookFn[]; + afterAll: SuiteHookFn[]; beforeEach: TestFn[]; afterEach: TestFn[]; status?: TestStatus; diff --git a/packages/bridge/src/shared/test-context.ts b/packages/bridge/src/shared/test-context.ts new file mode 100644 index 00000000..5bbd2a99 --- /dev/null +++ b/packages/bridge/src/shared/test-context.ts @@ -0,0 +1,15 @@ +export type HarnessTaskContext = { + name: string; + type: 'test'; + mode: 'run' | 'skip' | 'todo'; + file: { + name: string; + }; + suite: { + name: string; + }; +}; + +export type HarnessTestContext = { + task: HarnessTaskContext; +}; diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts new file mode 100644 index 00000000..2df21d5e --- /dev/null +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../symbolicate.js', () => ({ + getCodeFrame: vi.fn(async () => null), +})); + +import { + afterEach, + beforeEach, + describe as harnessDescribe, + getTestCollector, + it as harnessIt, +} from '../collector/index.js'; +import { getTestRunner } from '../runner/index.js'; + +describe('runner task context', () => { + it('passes minimal task metadata to tests and per-test hooks', async () => { + const observedTasks: Array<{ + source: 'beforeEach' | 'test' | 'afterEach'; + task: { + name: string; + type: 'test'; + mode: 'run' | 'skip' | 'todo'; + file: { name: string }; + suite: { name: string }; + }; + }> = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Task Context Suite', () => { + beforeEach((context) => { + observedTasks.push({ source: 'beforeEach', task: context!.task }); + }); + + afterEach((context) => { + observedTasks.push({ source: 'afterEach', task: context!.task }); + }); + + harnessIt('exposes task metadata', (context) => { + observedTasks.push({ source: 'test', task: context!.task }); + }); + }); + }, 'runtime/context.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/context.test.ts', + runner: 'ios', + }); + + expect(result.status).toBe('passed'); + expect(result.suites[0].tests[0]).toMatchObject({ + name: 'exposes task metadata', + status: 'passed', + }); + expect(observedTasks).toEqual([ + { + source: 'beforeEach', + task: { + name: 'exposes task metadata', + type: 'test', + mode: 'run', + file: { name: 'runtime/context.test.ts' }, + suite: { name: 'Task Context Suite' }, + }, + }, + { + source: 'test', + task: { + name: 'exposes task metadata', + type: 'test', + mode: 'run', + file: { name: 'runtime/context.test.ts' }, + suite: { name: 'Task Context Suite' }, + }, + }, + { + source: 'afterEach', + task: { + name: 'exposes task metadata', + type: 'test', + mode: 'run', + file: { name: 'runtime/context.test.ts' }, + suite: { name: 'Task Context Suite' }, + }, + }, + ]); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + + it('keeps zero-argument tests and hooks working', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Compatibility Suite', () => { + beforeEach(() => { + calls.push('beforeEach'); + }); + + afterEach(() => { + calls.push('afterEach'); + }); + + harnessIt('still runs', () => { + calls.push('test'); + }); + }); + }, 'runtime/compatibility.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/compatibility.test.ts', + runner: 'android', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'passed' }); + expect(calls).toEqual(['beforeEach', 'test', 'afterEach']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); +}); diff --git a/packages/runtime/src/collector/functions.ts b/packages/runtime/src/collector/functions.ts index f9862316..4259f939 100644 --- a/packages/runtime/src/collector/functions.ts +++ b/packages/runtime/src/collector/functions.ts @@ -2,6 +2,7 @@ import type { TestCase, TestSuite, CollectionResult, + SuiteHookFn, } from '@react-native-harness/bridge'; import type { TestFn } from './types.js'; import { TestError } from './errors.js'; @@ -24,8 +25,8 @@ type RawTestSuite = { tests: RawTestCase[]; suites: RawTestSuite[]; hooks: { - beforeAll: TestFn[]; - afterAll: TestFn[]; + beforeAll: SuiteHookFn[]; + afterAll: SuiteHookFn[]; beforeEach: TestFn[]; afterEach: TestFn[]; }; @@ -316,7 +317,7 @@ export const test = Object.assign( export const it = test; -export function beforeAll(fn: TestFn) { +export function beforeAll(fn: SuiteHookFn) { validateTestFunction(fn, 'beforeAll'); const currentSuite = getCurrentSuite(); @@ -326,7 +327,7 @@ export function beforeAll(fn: TestFn) { currentSuite.hooks.beforeAll.push(fn); } -export function afterAll(fn: TestFn) { +export function afterAll(fn: SuiteHookFn) { validateTestFunction(fn, 'afterAll'); const currentSuite = getCurrentSuite(); diff --git a/packages/runtime/src/collector/types.ts b/packages/runtime/src/collector/types.ts index 4dd96d88..de5afdf2 100644 --- a/packages/runtime/src/collector/types.ts +++ b/packages/runtime/src/collector/types.ts @@ -2,9 +2,12 @@ import { EventEmitter } from '../utils/emitter.js'; import { TestCollectorEvents, CollectionResult, + type HarnessTestContext, } from '@react-native-harness/bridge'; -export type TestFn = () => void | Promise; +export type TestFn = (context?: HarnessTestContext) => void | Promise; + +export type SuiteHookFn = () => void | Promise; export type TestCollectorEventsEmitter = EventEmitter; diff --git a/packages/runtime/src/collector/validation.ts b/packages/runtime/src/collector/validation.ts index 1c490bee..92851dfc 100644 --- a/packages/runtime/src/collector/validation.ts +++ b/packages/runtime/src/collector/validation.ts @@ -1,5 +1,5 @@ import { TestError } from './errors.js'; -import { TestFn } from './types.js'; +import { TestFn, SuiteHookFn } from './types.js'; export const validateTestName = (name: string, functionName: string): void => { if (!name || typeof name !== 'string' || name.trim() === '') { @@ -10,7 +10,7 @@ export const validateTestName = (name: string, functionName: string): void => { }; export const validateTestFunction = ( - fn: TestFn, + fn: TestFn | SuiteHookFn, functionName: string ): void => { if (typeof fn !== 'function') { diff --git a/packages/runtime/src/runner/hooks.ts b/packages/runtime/src/runner/hooks.ts index b6cf04d8..75877b6b 100644 --- a/packages/runtime/src/runner/hooks.ts +++ b/packages/runtime/src/runner/hooks.ts @@ -1,12 +1,13 @@ -import type { TestSuite } from '@react-native-harness/bridge'; +import type { SuiteHookFn, TestFn, TestSuite } from '@react-native-harness/bridge'; +import type { ActiveTestContext } from './types.js'; export type HookType = 'beforeEach' | 'afterEach' | 'beforeAll' | 'afterAll'; const collectInheritedHooks = ( suite: TestSuite, hookType: HookType -): (() => void | Promise)[] => { - const hooks: (() => void | Promise)[] = []; +): Array => { + const hooks: Array = []; const suiteChain: TestSuite[] = []; // Collect all suites from current to root @@ -41,11 +42,12 @@ const collectInheritedHooks = ( export const runHooks = async ( suite: TestSuite, - hookType: HookType + hookType: HookType, + context?: ActiveTestContext, ): Promise => { const hooks = collectInheritedHooks(suite, hookType); for (const hook of hooks) { - await hook(); + await hook(context); } }; diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 7dc5c008..c3ae928f 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -1,4 +1,5 @@ import type { + HarnessTaskContext, TestCase, TestResult, TestSuite, @@ -11,7 +12,7 @@ import { import { flushExpectTestState } from '../expect/errors.js'; import { runHooks } from './hooks.js'; import { getTestExecutionError } from './errors.js'; -import { TestRunnerContext } from './types.js'; +import { ActiveTestContext, TestRunnerContext } from './types.js'; declare global { var HARNESS_TEST_PATH: string; @@ -23,6 +24,23 @@ const runTest = async ( context: TestRunnerContext, ): Promise => { const startTime = Date.now(); + const task: HarnessTaskContext = { + name: test.name, + type: 'test', + mode: + test.status === 'active' + ? 'run' + : test.status === 'skipped' + ? 'skip' + : 'todo', + file: { + name: context.testFilePath, + }, + suite: { + name: suite.name, + }, + }; + const activeTestContext: ActiveTestContext = { task }; // Emit test-started event context.events.emit({ @@ -79,13 +97,13 @@ const runTest = async ( try { // Run all beforeEach hooks from the current suite and its parents - await runHooks(suite, 'beforeEach'); + await runHooks(suite, 'beforeEach', activeTestContext); // Run the actual test - await test.fn(); + await test.fn(activeTestContext); // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach'); + await runHooks(suite, 'afterEach', activeTestContext); await flushExpectTestState(expectTestState); } finally { diff --git a/packages/runtime/src/runner/types.ts b/packages/runtime/src/runner/types.ts index 2ec75fb8..2ed1d912 100644 --- a/packages/runtime/src/runner/types.ts +++ b/packages/runtime/src/runner/types.ts @@ -1,5 +1,6 @@ import { EventEmitter } from '../utils/emitter.js'; import type { + HarnessTestContext, TestRunnerEvents, TestSuite, TestSuiteResult, @@ -12,6 +13,8 @@ export type TestRunnerContext = { testFilePath: string; }; +export type ActiveTestContext = HarnessTestContext; + export type RunTestsOptions = { testSuite: TestSuite; testFilePath: string; From dda80c6f4fc3a543b3842424ef4bf4168fb48bf9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 19 May 2026 19:29:46 +0200 Subject: [PATCH 2/9] feat(runtime): support dynamic test skipping --- packages/bridge/src/shared/test-context.ts | 4 + .../src/__tests__/runner-context.test.ts | 76 +++++++++++++++++++ packages/runtime/src/runner/runSuite.ts | 50 +++++++++--- packages/runtime/src/runner/test-context.ts | 41 ++++++++++ 4 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 packages/runtime/src/runner/test-context.ts diff --git a/packages/bridge/src/shared/test-context.ts b/packages/bridge/src/shared/test-context.ts index 5bbd2a99..1a693e2e 100644 --- a/packages/bridge/src/shared/test-context.ts +++ b/packages/bridge/src/shared/test-context.ts @@ -12,4 +12,8 @@ export type HarnessTaskContext = { export type HarnessTestContext = { task: HarnessTaskContext; + skip: { + (note?: string): never; + (condition: boolean, note?: string): void; + }; }; diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index 2df21d5e..8e45ca7c 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -129,4 +129,80 @@ describe('runner task context', () => { runner.dispose(); } }); + + it('marks dynamically skipped tests as skipped and still runs afterEach', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Skip Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + + harnessIt('skips from context', ({ skip }) => { + calls.push('before-skip'); + skip('skip this test'); + calls.push('after-skip'); + }); + + harnessIt('still runs sibling test', () => { + calls.push('sibling'); + }); + }); + }, 'runtime/skip.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/skip.test.ts', + runner: 'ios', + }); + + expect(result.suites[0].tests).toMatchObject([ + { name: 'skips from context', status: 'skipped' }, + { name: 'still runs sibling test', status: 'passed' }, + ]); + expect(calls).toEqual([ + 'before-skip', + 'afterEach', + 'sibling', + 'afterEach', + ]); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + + it('supports conditional skipping without changing false conditions', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Conditional Skip Suite', () => { + harnessIt('continues when condition is false', ({ skip }) => { + calls.push('before'); + skip(false, 'do not skip'); + calls.push('after'); + }); + }); + }, 'runtime/conditional-skip.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/conditional-skip.test.ts', + runner: 'android', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'passed' }); + expect(calls).toEqual(['before', 'after']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); }); diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index c3ae928f..54640cbd 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -13,6 +13,7 @@ import { flushExpectTestState } from '../expect/errors.js'; import { runHooks } from './hooks.js'; import { getTestExecutionError } from './errors.js'; import { ActiveTestContext, TestRunnerContext } from './types.js'; +import { createTestContext, isSkipTestError } from './test-context.js'; declare global { var HARNESS_TEST_PATH: string; @@ -40,7 +41,7 @@ const runTest = async ( name: suite.name, }, }; - const activeTestContext: ActiveTestContext = { task }; + const activeTestContext: ActiveTestContext = createTestContext(task); // Emit test-started event context.events.emit({ @@ -96,14 +97,45 @@ const runTest = async ( setCurrentExpectTestState(expectTestState); try { - // Run all beforeEach hooks from the current suite and its parents - await runHooks(suite, 'beforeEach', activeTestContext); - - // Run the actual test - await test.fn(activeTestContext); - - // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach', activeTestContext); + let didSkip = false; + + try { + // Run all beforeEach hooks from the current suite and its parents + await runHooks(suite, 'beforeEach', activeTestContext); + + // Run the actual test + await test.fn(activeTestContext); + } catch (error) { + if (!isSkipTestError(error)) { + throw error; + } + + didSkip = true; + } finally { + // Run all afterEach hooks from the current suite and its parents + await runHooks(suite, 'afterEach', activeTestContext); + } + + if (didSkip) { + const duration = Date.now() - startTime; + + const result = { + name: test.name, + status: 'skipped' as const, + duration, + }; + + context.events.emit({ + type: 'test-finished', + file: context.testFilePath, + suite: suite.name, + name: test.name, + duration, + status: 'skipped', + }); + + return result; + } await flushExpectTestState(expectTestState); } finally { diff --git a/packages/runtime/src/runner/test-context.ts b/packages/runtime/src/runner/test-context.ts new file mode 100644 index 00000000..bdfd33b3 --- /dev/null +++ b/packages/runtime/src/runner/test-context.ts @@ -0,0 +1,41 @@ +import type { HarnessTaskContext } from '@react-native-harness/bridge'; +import type { ActiveTestContext } from './types.js'; + +export class SkipTestError extends Error { + note?: string; + + constructor(note?: string) { + super(note ?? 'Test skipped'); + this.name = 'SkipTestError'; + this.note = note; + } +} + +export const isSkipTestError = (error: unknown): error is SkipTestError => { + return error instanceof SkipTestError; +}; + +const createSkip = () => { + function skip(noteOrCondition?: boolean | string, note?: string): void { + if (typeof noteOrCondition === 'boolean') { + if (!noteOrCondition) { + return; + } + + throw new SkipTestError(note); + } + + throw new SkipTestError(noteOrCondition); + } + + return skip as ActiveTestContext['skip']; +}; + +export const createTestContext = ( + task: HarnessTaskContext, +): ActiveTestContext => { + return { + task, + skip: createSkip(), + }; +}; From 6e2ff5d5d90dfbd73dc9c04010a6d70e8d708131 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 19 May 2026 19:31:31 +0200 Subject: [PATCH 3/9] feat(runtime): add onTestFinished hook --- packages/bridge/src/shared/test-context.ts | 1 + .../src/__tests__/runner-context.test.ts | 118 ++++++++++++++++++ packages/runtime/src/runner/runSuite.ts | 18 ++- packages/runtime/src/runner/test-context.ts | 26 ++++ 4 files changed, 161 insertions(+), 2 deletions(-) diff --git a/packages/bridge/src/shared/test-context.ts b/packages/bridge/src/shared/test-context.ts index 1a693e2e..852a936e 100644 --- a/packages/bridge/src/shared/test-context.ts +++ b/packages/bridge/src/shared/test-context.ts @@ -12,6 +12,7 @@ export type HarnessTaskContext = { export type HarnessTestContext = { task: HarnessTaskContext; + onTestFinished: (fn: () => void | Promise) => void; skip: { (note?: string): never; (condition: boolean, note?: string): void; diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index 8e45ca7c..fc5eff3d 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -205,4 +205,122 @@ describe('runner task context', () => { runner.dispose(); } }); + + it('runs onTestFinished after afterEach for passing tests', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Finished Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + + harnessIt('runs finished callbacks', ({ onTestFinished }) => { + onTestFinished(() => { + calls.push('onTestFinished:first'); + }); + onTestFinished(() => { + calls.push('onTestFinished:second'); + }); + + calls.push('test'); + }); + }); + }, 'runtime/on-test-finished-pass.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/on-test-finished-pass.test.ts', + runner: 'ios', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'passed' }); + expect(calls).toEqual([ + 'test', + 'afterEach', + 'onTestFinished:second', + 'onTestFinished:first', + ]); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + + it('runs onTestFinished for dynamically skipped tests', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Finished Skip Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + + harnessIt('runs finished callback after skip', ({ onTestFinished, skip }) => { + onTestFinished(() => { + calls.push('onTestFinished'); + }); + + calls.push('before-skip'); + skip(); + }); + }); + }, 'runtime/on-test-finished-skip.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/on-test-finished-skip.test.ts', + runner: 'android', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'skipped' }); + expect(calls).toEqual(['before-skip', 'afterEach', 'onTestFinished']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + + it('runs onTestFinished for failed tests', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Finished Failure Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + + harnessIt('runs finished callback after failure', ({ onTestFinished }) => { + onTestFinished(() => { + calls.push('onTestFinished'); + }); + + calls.push('test'); + throw new Error('expected failure'); + }); + }); + }, 'runtime/on-test-finished-failure.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/on-test-finished-failure.test.ts', + runner: 'ios', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'failed' }); + expect(calls).toEqual(['test', 'afterEach', 'onTestFinished']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); }); diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 54640cbd..cdb2d9f7 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -13,7 +13,12 @@ import { flushExpectTestState } from '../expect/errors.js'; import { runHooks } from './hooks.js'; import { getTestExecutionError } from './errors.js'; import { ActiveTestContext, TestRunnerContext } from './types.js'; -import { createTestContext, isSkipTestError } from './test-context.js'; +import { + createTestContext, + createTestLifecycleState, + isSkipTestError, + runOnTestFinished, +} from './test-context.js'; declare global { var HARNESS_TEST_PATH: string; @@ -41,7 +46,11 @@ const runTest = async ( name: suite.name, }, }; - const activeTestContext: ActiveTestContext = createTestContext(task); + const lifecycleState = createTestLifecycleState(); + const activeTestContext: ActiveTestContext = createTestContext( + task, + lifecycleState, + ); // Emit test-started event context.events.emit({ @@ -119,6 +128,8 @@ const runTest = async ( if (didSkip) { const duration = Date.now() - startTime; + await runOnTestFinished(lifecycleState); + const result = { name: test.name, status: 'skipped' as const, @@ -138,6 +149,7 @@ const runTest = async ( } await flushExpectTestState(expectTestState); + await runOnTestFinished(lifecycleState); } finally { setCurrentExpectTestState(undefined); } @@ -162,6 +174,8 @@ const runTest = async ( return result; } catch (error) { + await runOnTestFinished(lifecycleState); + const testError = await getTestExecutionError( error, context.testFilePath, diff --git a/packages/runtime/src/runner/test-context.ts b/packages/runtime/src/runner/test-context.ts index bdfd33b3..a2b56a91 100644 --- a/packages/runtime/src/runner/test-context.ts +++ b/packages/runtime/src/runner/test-context.ts @@ -1,6 +1,10 @@ import type { HarnessTaskContext } from '@react-native-harness/bridge'; import type { ActiveTestContext } from './types.js'; +export type TestLifecycleState = { + onTestFinished: Array<() => void | Promise>; +}; + export class SkipTestError extends Error { note?: string; @@ -31,11 +35,33 @@ const createSkip = () => { return skip as ActiveTestContext['skip']; }; +const createOnTestFinished = (state: TestLifecycleState) => { + return (fn: () => void | Promise): void => { + state.onTestFinished.push(fn); + }; +}; + +export const createTestLifecycleState = (): TestLifecycleState => { + return { + onTestFinished: [], + }; +}; + +export const runOnTestFinished = async ( + state: TestLifecycleState, +): Promise => { + for (let i = state.onTestFinished.length - 1; i >= 0; i--) { + await state.onTestFinished[i](); + } +}; + export const createTestContext = ( task: HarnessTaskContext, + state: TestLifecycleState, ): ActiveTestContext => { return { task, + onTestFinished: createOnTestFinished(state), skip: createSkip(), }; }; From 06c228dbdb1ce62421a31806cbdac1cad0580d30 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 19 May 2026 19:32:17 +0200 Subject: [PATCH 4/9] feat(runtime): add onTestFailed hook --- packages/bridge/src/shared/test-context.ts | 1 + .../src/__tests__/runner-context.test.ts | 119 ++++++++++++++++++ packages/runtime/src/runner/runSuite.ts | 2 + packages/runtime/src/runner/test-context.ts | 17 +++ 4 files changed, 139 insertions(+) diff --git a/packages/bridge/src/shared/test-context.ts b/packages/bridge/src/shared/test-context.ts index 852a936e..e96e3955 100644 --- a/packages/bridge/src/shared/test-context.ts +++ b/packages/bridge/src/shared/test-context.ts @@ -12,6 +12,7 @@ export type HarnessTaskContext = { export type HarnessTestContext = { task: HarnessTaskContext; + onTestFailed: (fn: () => void | Promise) => void; onTestFinished: (fn: () => void | Promise) => void; skip: { (note?: string): never; diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index fc5eff3d..33f21353 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -323,4 +323,123 @@ describe('runner task context', () => { runner.dispose(); } }); + + it('runs onTestFailed after afterEach for failed tests', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Failed Hook Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + + harnessIt('runs failed callbacks', ({ onTestFailed }) => { + onTestFailed(() => { + calls.push('onTestFailed:first'); + }); + onTestFailed(() => { + calls.push('onTestFailed:second'); + }); + + calls.push('test'); + throw new Error('expected failure'); + }); + }); + }, 'runtime/on-test-failed-failure.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/on-test-failed-failure.test.ts', + runner: 'ios', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'failed' }); + expect(calls).toEqual([ + 'test', + 'afterEach', + 'onTestFailed:second', + 'onTestFailed:first', + ]); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + + it('does not run onTestFailed for dynamically skipped tests', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Failed Skip Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + + harnessIt('does not run failed callbacks on skip', ({ onTestFailed, skip }) => { + onTestFailed(() => { + calls.push('onTestFailed'); + }); + + calls.push('before-skip'); + skip(); + }); + }); + }, 'runtime/on-test-failed-skip.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/on-test-failed-skip.test.ts', + runner: 'android', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'skipped' }); + expect(calls).toEqual(['before-skip', 'afterEach']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + + it('runs onTestFailed when afterEach fails', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Failed AfterEach Suite', () => { + afterEach(() => { + calls.push('afterEach'); + throw new Error('afterEach failure'); + }); + + harnessIt('runs failed callback after afterEach failure', ({ onTestFailed }) => { + onTestFailed(() => { + calls.push('onTestFailed'); + }); + + calls.push('test'); + }); + }); + }, 'runtime/on-test-failed-after-each.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/on-test-failed-after-each.test.ts', + runner: 'ios', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'failed' }); + expect(calls).toEqual(['test', 'afterEach', 'onTestFailed']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); }); diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index cdb2d9f7..c1f2ec25 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -17,6 +17,7 @@ import { createTestContext, createTestLifecycleState, isSkipTestError, + runOnTestFailed, runOnTestFinished, } from './test-context.js'; @@ -174,6 +175,7 @@ const runTest = async ( return result; } catch (error) { + await runOnTestFailed(lifecycleState); await runOnTestFinished(lifecycleState); const testError = await getTestExecutionError( diff --git a/packages/runtime/src/runner/test-context.ts b/packages/runtime/src/runner/test-context.ts index a2b56a91..42edf69d 100644 --- a/packages/runtime/src/runner/test-context.ts +++ b/packages/runtime/src/runner/test-context.ts @@ -2,6 +2,7 @@ import type { HarnessTaskContext } from '@react-native-harness/bridge'; import type { ActiveTestContext } from './types.js'; export type TestLifecycleState = { + onTestFailed: Array<() => void | Promise>; onTestFinished: Array<() => void | Promise>; }; @@ -41,12 +42,27 @@ const createOnTestFinished = (state: TestLifecycleState) => { }; }; +const createOnTestFailed = (state: TestLifecycleState) => { + return (fn: () => void | Promise): void => { + state.onTestFailed.push(fn); + }; +}; + export const createTestLifecycleState = (): TestLifecycleState => { return { + onTestFailed: [], onTestFinished: [], }; }; +export const runOnTestFailed = async ( + state: TestLifecycleState, +): Promise => { + for (let i = state.onTestFailed.length - 1; i >= 0; i--) { + await state.onTestFailed[i](); + } +}; + export const runOnTestFinished = async ( state: TestLifecycleState, ): Promise => { @@ -61,6 +77,7 @@ export const createTestContext = ( ): ActiveTestContext => { return { task, + onTestFailed: createOnTestFailed(state), onTestFinished: createOnTestFinished(state), skip: createSkip(), }; From 780a6697e5fdbfe612bb245650e948589c600a1f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 19 May 2026 19:33:45 +0200 Subject: [PATCH 5/9] chore(runtime): clean up runner context tests --- .../src/__tests__/runner-context.test.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index 33f21353..4cc542ba 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -1,10 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('../symbolicate.js', () => ({ - getCodeFrame: vi.fn(async () => null), -})); - import { + type HarnessTestContext, afterEach, beforeEach, describe as harnessDescribe, @@ -12,6 +7,26 @@ import { it as harnessIt, } from '../collector/index.js'; import { getTestRunner } from '../runner/index.js'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../symbolicate.js', async () => { + const actual = await vi.importActual( + '../symbolicate.js', + ); + + return { + ...actual, + getCodeFrame: vi.fn(async () => null), + }; +}); + +const getTask = (context?: HarnessTestContext) => { + if (!context) { + throw new Error('Expected test context to be defined'); + } + + return context.task; +}; describe('runner task context', () => { it('passes minimal task metadata to tests and per-test hooks', async () => { @@ -32,15 +47,15 @@ describe('runner task context', () => { const collection = await collector.collect(() => { harnessDescribe('Task Context Suite', () => { beforeEach((context) => { - observedTasks.push({ source: 'beforeEach', task: context!.task }); + observedTasks.push({ source: 'beforeEach', task: getTask(context) }); }); afterEach((context) => { - observedTasks.push({ source: 'afterEach', task: context!.task }); + observedTasks.push({ source: 'afterEach', task: getTask(context) }); }); harnessIt('exposes task metadata', (context) => { - observedTasks.push({ source: 'test', task: context!.task }); + observedTasks.push({ source: 'test', task: getTask(context) }); }); }); }, 'runtime/context.test.ts'); From 84376afd952542973bc21db201c9da2b94c8a38a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 19 May 2026 19:40:52 +0200 Subject: [PATCH 6/9] fix(runtime): stabilize runtime test environment --- .../src/__tests__/runner-context.test.ts | 73 +++++++++++++------ .../runtime/src/client/getWSServer.test.ts | 4 - packages/runtime/src/client/getWSServer.ts | 3 +- packages/runtime/src/react-native.d.ts | 4 + 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index 4cc542ba..e44e9b87 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -1,21 +1,16 @@ import { - type HarnessTestContext, afterEach, beforeEach, describe as harnessDescribe, getTestCollector, it as harnessIt, } from '../collector/index.js'; +import type { HarnessTestContext } from '@react-native-harness/bridge'; import { getTestRunner } from '../runner/index.js'; import { describe, expect, it, vi } from 'vitest'; vi.mock('../symbolicate.js', async () => { - const actual = await vi.importActual( - '../symbolicate.js', - ); - return { - ...actual, getCodeFrame: vi.fn(async () => null), }; }); @@ -28,6 +23,14 @@ const getTask = (context?: HarnessTestContext) => { return context.task; }; +const getTaskContext = (context?: HarnessTestContext) => { + if (!context) { + throw new Error('Expected test context to be defined'); + } + + return context; +}; + describe('runner task context', () => { it('passes minimal task metadata to tests and per-test hooks', async () => { const observedTasks: Array<{ @@ -46,15 +49,15 @@ describe('runner task context', () => { try { const collection = await collector.collect(() => { harnessDescribe('Task Context Suite', () => { - beforeEach((context) => { + beforeEach((context: HarnessTestContext | undefined) => { observedTasks.push({ source: 'beforeEach', task: getTask(context) }); }); - afterEach((context) => { + afterEach((context: HarnessTestContext | undefined) => { observedTasks.push({ source: 'afterEach', task: getTask(context) }); }); - harnessIt('exposes task metadata', (context) => { + harnessIt('exposes task metadata', (context: HarnessTestContext | undefined) => { observedTasks.push({ source: 'test', task: getTask(context) }); }); }); @@ -157,7 +160,9 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('skips from context', ({ skip }) => { + harnessIt('skips from context', (context: HarnessTestContext | undefined) => { + const { skip } = getTaskContext(context); + calls.push('before-skip'); skip('skip this test'); calls.push('after-skip'); @@ -199,7 +204,9 @@ describe('runner task context', () => { try { const collection = await collector.collect(() => { harnessDescribe('Conditional Skip Suite', () => { - harnessIt('continues when condition is false', ({ skip }) => { + harnessIt('continues when condition is false', (context: HarnessTestContext | undefined) => { + const { skip } = getTaskContext(context); + calls.push('before'); skip(false, 'do not skip'); calls.push('after'); @@ -233,7 +240,9 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('runs finished callbacks', ({ onTestFinished }) => { + harnessIt('runs finished callbacks', (context: HarnessTestContext | undefined) => { + const { onTestFinished } = getTaskContext(context); + onTestFinished(() => { calls.push('onTestFinished:first'); }); @@ -277,14 +286,19 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('runs finished callback after skip', ({ onTestFinished, skip }) => { + harnessIt( + 'runs finished callback after skip', + (context: HarnessTestContext | undefined) => { + const { onTestFinished, skip } = getTaskContext(context); + onTestFinished(() => { calls.push('onTestFinished'); }); calls.push('before-skip'); skip(); - }); + }, + ); }); }, 'runtime/on-test-finished-skip.test.ts'); @@ -314,14 +328,19 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('runs finished callback after failure', ({ onTestFinished }) => { + harnessIt( + 'runs finished callback after failure', + (context: HarnessTestContext | undefined) => { + const { onTestFinished } = getTaskContext(context); + onTestFinished(() => { calls.push('onTestFinished'); }); calls.push('test'); throw new Error('expected failure'); - }); + }, + ); }); }, 'runtime/on-test-finished-failure.test.ts'); @@ -351,7 +370,9 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('runs failed callbacks', ({ onTestFailed }) => { + harnessIt('runs failed callbacks', (context: HarnessTestContext | undefined) => { + const { onTestFailed } = getTaskContext(context); + onTestFailed(() => { calls.push('onTestFailed:first'); }); @@ -396,14 +417,19 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('does not run failed callbacks on skip', ({ onTestFailed, skip }) => { + harnessIt( + 'does not run failed callbacks on skip', + (context: HarnessTestContext | undefined) => { + const { onTestFailed, skip } = getTaskContext(context); + onTestFailed(() => { calls.push('onTestFailed'); }); calls.push('before-skip'); skip(); - }); + }, + ); }); }, 'runtime/on-test-failed-skip.test.ts'); @@ -434,13 +460,18 @@ describe('runner task context', () => { throw new Error('afterEach failure'); }); - harnessIt('runs failed callback after afterEach failure', ({ onTestFailed }) => { + harnessIt( + 'runs failed callback after afterEach failure', + (context: HarnessTestContext | undefined) => { + const { onTestFailed } = getTaskContext(context); + onTestFailed(() => { calls.push('onTestFailed'); }); calls.push('test'); - }); + }, + ); }); }, 'runtime/on-test-failed-after-each.test.ts'); diff --git a/packages/runtime/src/client/getWSServer.test.ts b/packages/runtime/src/client/getWSServer.test.ts index 8351373d..566064bf 100644 --- a/packages/runtime/src/client/getWSServer.test.ts +++ b/packages/runtime/src/client/getWSServer.test.ts @@ -10,10 +10,6 @@ vi.mock('../utils/dev-server.js', () => ({ getDevServerUrl: mocks.getDevServerUrl, })); -vi.mock('react-native-url-polyfill', () => ({ - URL, -})); - describe('getWSServer', () => { beforeEach(() => { mocks.getDevServerUrl.mockReset(); diff --git a/packages/runtime/src/client/getWSServer.ts b/packages/runtime/src/client/getWSServer.ts index 79bd434a..82bbe4c6 100644 --- a/packages/runtime/src/client/getWSServer.ts +++ b/packages/runtime/src/client/getWSServer.ts @@ -1,10 +1,9 @@ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; -import { URL } from 'react-native-url-polyfill'; import { getDevServerUrl } from '../utils/dev-server.js'; export const getWSServer = (): string => { const devServerUrlString = getDevServerUrl(); - const devServerUrl = new URL(devServerUrlString); + const devServerUrl = new globalThis.URL(devServerUrlString); if (!devServerUrl.host) { throw new TypeError(`Invalid URL: ${devServerUrlString}`); diff --git a/packages/runtime/src/react-native.d.ts b/packages/runtime/src/react-native.d.ts index ea169d86..a0f626fa 100644 --- a/packages/runtime/src/react-native.d.ts +++ b/packages/runtime/src/react-native.d.ts @@ -17,6 +17,10 @@ declare module 'react-native/Libraries/Core/Devtools/parseErrorStack' { export default function parseErrorStack(errorStack?: string): StackFrame[]; } +declare module 'react-native-url-polyfill' { + export const URL: typeof globalThis.URL; +} + declare module '*.png' { import type { ImageSourcePropType } from 'react-native'; From aa33ab8c750a38f7621b476c6ae5bd3c58c26107 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 19 May 2026 20:09:03 +0200 Subject: [PATCH 7/9] test(runtime): mock react-native URL polyfill --- packages/runtime/src/client/getWSServer.test.ts | 4 ++++ packages/runtime/src/client/getWSServer.ts | 3 ++- packages/runtime/src/test-utils/react-native-url-polyfill.ts | 1 + packages/runtime/vite.config.ts | 4 ++++ 4 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/test-utils/react-native-url-polyfill.ts diff --git a/packages/runtime/src/client/getWSServer.test.ts b/packages/runtime/src/client/getWSServer.test.ts index 566064bf..8351373d 100644 --- a/packages/runtime/src/client/getWSServer.test.ts +++ b/packages/runtime/src/client/getWSServer.test.ts @@ -10,6 +10,10 @@ vi.mock('../utils/dev-server.js', () => ({ getDevServerUrl: mocks.getDevServerUrl, })); +vi.mock('react-native-url-polyfill', () => ({ + URL, +})); + describe('getWSServer', () => { beforeEach(() => { mocks.getDevServerUrl.mockReset(); diff --git a/packages/runtime/src/client/getWSServer.ts b/packages/runtime/src/client/getWSServer.ts index 82bbe4c6..79bd434a 100644 --- a/packages/runtime/src/client/getWSServer.ts +++ b/packages/runtime/src/client/getWSServer.ts @@ -1,9 +1,10 @@ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; +import { URL } from 'react-native-url-polyfill'; import { getDevServerUrl } from '../utils/dev-server.js'; export const getWSServer = (): string => { const devServerUrlString = getDevServerUrl(); - const devServerUrl = new globalThis.URL(devServerUrlString); + const devServerUrl = new URL(devServerUrlString); if (!devServerUrl.host) { throw new TypeError(`Invalid URL: ${devServerUrlString}`); diff --git a/packages/runtime/src/test-utils/react-native-url-polyfill.ts b/packages/runtime/src/test-utils/react-native-url-polyfill.ts new file mode 100644 index 00000000..3eb261c9 --- /dev/null +++ b/packages/runtime/src/test-utils/react-native-url-polyfill.ts @@ -0,0 +1 @@ +export const URL = globalThis.URL; diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts index 495ae598..31fa11bc 100644 --- a/packages/runtime/vite.config.ts +++ b/packages/runtime/vite.config.ts @@ -22,6 +22,10 @@ export default defineConfig(() => ({ alias: { '@vitest/spy': path.resolve(__dirname, 'node_modules/@vitest/spy'), '@vitest/expect': path.resolve(__dirname, 'node_modules/@vitest/expect'), + 'react-native-url-polyfill': path.resolve( + __dirname, + 'src/test-utils/react-native-url-polyfill.ts', + ), }, }, })); From 4e3fff019be28694acc6b2c7371051615526e081 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 20 May 2026 08:35:34 +0200 Subject: [PATCH 8/9] fix(runtime): require test context --- apps/playground/rn-harness.config.mjs | 2 +- .../playground/src/__tests__/smoke.harness.ts | 13 +++++ packages/bridge/src/shared/test-collector.ts | 2 +- packages/coverage-ios/tsconfig.json | 3 + packages/coverage-ios/tsconfig.lib.json | 7 ++- .../src/__tests__/runner-context.test.ts | 34 +++++------ packages/runtime/src/collector/types.ts | 2 +- packages/runtime/src/react-native.d.ts | 4 -- packages/runtime/src/runner/hooks.ts | 56 +++++++++++++------ tsconfig.json | 3 + 10 files changed, 80 insertions(+), 46 deletions(-) diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 559f19f8..0b278b46 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -79,7 +79,7 @@ export default { }), applePlatform({ name: 'ios', - device: appleSimulator('iPhone 17 Pro', '26.2'), + device: appleSimulator('iPhone 17 Pro', '26.4'), bundleId: 'com.harnessplayground', }), applePlatform({ diff --git a/apps/playground/src/__tests__/smoke.harness.ts b/apps/playground/src/__tests__/smoke.harness.ts index d94a3fa2..393fb879 100644 --- a/apps/playground/src/__tests__/smoke.harness.ts +++ b/apps/playground/src/__tests__/smoke.harness.ts @@ -4,4 +4,17 @@ describe('Smoke test', () => { test('should run a simple test', () => { expect(1 + 1).toBe(2); }); + + test('should expose task context to tests', (context) => { + expect(context).toBeDefined(); + expect(context.task.type).toBe('test'); + expect(context.task.mode).toBe('run'); + expect(context.task.file.name).toBe('src/__tests__/smoke.harness.ts'); + expect(context.task.suite.name).toBe('Smoke test'); + expect(context.task.name).toBe('should expose task context to tests'); + }); + + test('should report dynamic skips as skipped', (context) => { + context.skip('skip from test context'); + }); }); diff --git a/packages/bridge/src/shared/test-collector.ts b/packages/bridge/src/shared/test-collector.ts index 040f0c93..94f27886 100644 --- a/packages/bridge/src/shared/test-collector.ts +++ b/packages/bridge/src/shared/test-collector.ts @@ -2,7 +2,7 @@ import type { HarnessTestContext } from './test-context.js'; export type TestStatus = 'active' | 'skipped' | 'todo'; -export type TestFn = (context?: HarnessTestContext) => void | Promise; +export type TestFn = (context: HarnessTestContext) => void | Promise; export type SuiteHookFn = () => void | Promise; diff --git a/packages/coverage-ios/tsconfig.json b/packages/coverage-ios/tsconfig.json index c23e61c8..af1b3657 100644 --- a/packages/coverage-ios/tsconfig.json +++ b/packages/coverage-ios/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../config" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/coverage-ios/tsconfig.lib.json b/packages/coverage-ios/tsconfig.lib.json index 7370b55e..385885be 100644 --- a/packages/coverage-ios/tsconfig.lib.json +++ b/packages/coverage-ios/tsconfig.lib.json @@ -10,5 +10,10 @@ "types": ["node"], "lib": ["DOM", "ES2022"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../config/tsconfig.lib.json" + } + ] } diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index e44e9b87..89289a47 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -15,19 +15,11 @@ vi.mock('../symbolicate.js', async () => { }; }); -const getTask = (context?: HarnessTestContext) => { - if (!context) { - throw new Error('Expected test context to be defined'); - } - +const getTask = (context: HarnessTestContext) => { return context.task; }; -const getTaskContext = (context?: HarnessTestContext) => { - if (!context) { - throw new Error('Expected test context to be defined'); - } - +const getTaskContext = (context: HarnessTestContext) => { return context; }; @@ -49,15 +41,15 @@ describe('runner task context', () => { try { const collection = await collector.collect(() => { harnessDescribe('Task Context Suite', () => { - beforeEach((context: HarnessTestContext | undefined) => { + beforeEach((context: HarnessTestContext) => { observedTasks.push({ source: 'beforeEach', task: getTask(context) }); }); - afterEach((context: HarnessTestContext | undefined) => { + afterEach((context: HarnessTestContext) => { observedTasks.push({ source: 'afterEach', task: getTask(context) }); }); - harnessIt('exposes task metadata', (context: HarnessTestContext | undefined) => { + harnessIt('exposes task metadata', (context: HarnessTestContext) => { observedTasks.push({ source: 'test', task: getTask(context) }); }); }); @@ -160,7 +152,7 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('skips from context', (context: HarnessTestContext | undefined) => { + harnessIt('skips from context', (context: HarnessTestContext) => { const { skip } = getTaskContext(context); calls.push('before-skip'); @@ -204,7 +196,7 @@ describe('runner task context', () => { try { const collection = await collector.collect(() => { harnessDescribe('Conditional Skip Suite', () => { - harnessIt('continues when condition is false', (context: HarnessTestContext | undefined) => { + harnessIt('continues when condition is false', (context: HarnessTestContext) => { const { skip } = getTaskContext(context); calls.push('before'); @@ -240,7 +232,7 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('runs finished callbacks', (context: HarnessTestContext | undefined) => { + harnessIt('runs finished callbacks', (context: HarnessTestContext) => { const { onTestFinished } = getTaskContext(context); onTestFinished(() => { @@ -288,7 +280,7 @@ describe('runner task context', () => { harnessIt( 'runs finished callback after skip', - (context: HarnessTestContext | undefined) => { + (context: HarnessTestContext) => { const { onTestFinished, skip } = getTaskContext(context); onTestFinished(() => { @@ -330,7 +322,7 @@ describe('runner task context', () => { harnessIt( 'runs finished callback after failure', - (context: HarnessTestContext | undefined) => { + (context: HarnessTestContext) => { const { onTestFinished } = getTaskContext(context); onTestFinished(() => { @@ -370,7 +362,7 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('runs failed callbacks', (context: HarnessTestContext | undefined) => { + harnessIt('runs failed callbacks', (context: HarnessTestContext) => { const { onTestFailed } = getTaskContext(context); onTestFailed(() => { @@ -419,7 +411,7 @@ describe('runner task context', () => { harnessIt( 'does not run failed callbacks on skip', - (context: HarnessTestContext | undefined) => { + (context: HarnessTestContext) => { const { onTestFailed, skip } = getTaskContext(context); onTestFailed(() => { @@ -462,7 +454,7 @@ describe('runner task context', () => { harnessIt( 'runs failed callback after afterEach failure', - (context: HarnessTestContext | undefined) => { + (context: HarnessTestContext) => { const { onTestFailed } = getTaskContext(context); onTestFailed(() => { diff --git a/packages/runtime/src/collector/types.ts b/packages/runtime/src/collector/types.ts index de5afdf2..081700f8 100644 --- a/packages/runtime/src/collector/types.ts +++ b/packages/runtime/src/collector/types.ts @@ -5,7 +5,7 @@ import { type HarnessTestContext, } from '@react-native-harness/bridge'; -export type TestFn = (context?: HarnessTestContext) => void | Promise; +export type TestFn = (context: HarnessTestContext) => void | Promise; export type SuiteHookFn = () => void | Promise; diff --git a/packages/runtime/src/react-native.d.ts b/packages/runtime/src/react-native.d.ts index a0f626fa..ea169d86 100644 --- a/packages/runtime/src/react-native.d.ts +++ b/packages/runtime/src/react-native.d.ts @@ -17,10 +17,6 @@ declare module 'react-native/Libraries/Core/Devtools/parseErrorStack' { export default function parseErrorStack(errorStack?: string): StackFrame[]; } -declare module 'react-native-url-polyfill' { - export const URL: typeof globalThis.URL; -} - declare module '*.png' { import type { ImageSourcePropType } from 'react-native'; diff --git a/packages/runtime/src/runner/hooks.ts b/packages/runtime/src/runner/hooks.ts index 75877b6b..69b47a6a 100644 --- a/packages/runtime/src/runner/hooks.ts +++ b/packages/runtime/src/runner/hooks.ts @@ -5,9 +5,29 @@ export type HookType = 'beforeEach' | 'afterEach' | 'beforeAll' | 'afterAll'; const collectInheritedHooks = ( suite: TestSuite, - hookType: HookType -): Array => { - const hooks: Array = []; + hookType: 'beforeEach' | 'afterEach' +): TestFn[] => { + const hooks: TestFn[] = []; + const suiteChain: TestSuite[] = []; + + let current: TestSuite | undefined = suite; + while (current) { + suiteChain.unshift(current); + current = current.parent; + } + + for (const currentSuite of suiteChain) { + hooks.push(...currentSuite[hookType]); + } + + return hooks; +}; + +const collectSuiteHooks = ( + suite: TestSuite, + hookType: 'beforeAll' | 'afterAll' +): SuiteHookFn[] => { + const hooks: SuiteHookFn[] = []; const suiteChain: TestSuite[] = []; // Collect all suites from current to root @@ -17,23 +37,15 @@ const collectInheritedHooks = ( currentSuite = currentSuite.parent; } - if (hookType === 'beforeEach' || hookType === 'beforeAll') { - // For beforeEach/beforeAll: run parent hooks first (reverse the chain) + if (hookType === 'beforeAll') { + // Run parent suite hooks before child suite hooks. for (let i = suiteChain.length - 1; i >= 0; i--) { - if (hookType === 'beforeEach') { - hooks.push(...suiteChain[i].beforeEach); - } else { - hooks.push(...suiteChain[i].beforeAll); - } + hooks.push(...suiteChain[i].beforeAll); } } else { - // For afterEach/afterAll: run child hooks first (use chain as-is) + // Run child suite hooks before parent suite hooks. for (const suiteInChain of suiteChain) { - if (hookType === 'afterEach') { - hooks.push(...suiteInChain.afterEach); - } else { - hooks.push(...suiteInChain.afterAll); - } + hooks.push(...suiteInChain.afterAll); } } @@ -45,9 +57,19 @@ export const runHooks = async ( hookType: HookType, context?: ActiveTestContext, ): Promise => { + if (hookType === 'beforeAll' || hookType === 'afterAll') { + const hooks = collectSuiteHooks(suite, hookType); + + for (const hook of hooks) { + await hook(); + } + + return; + } + const hooks = collectInheritedHooks(suite, hookType); for (const hook of hooks) { - await hook(context); + await hook(context as ActiveTestContext); } }; diff --git a/tsconfig.json b/tsconfig.json index 4d6eb235..17c62baa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -62,6 +62,9 @@ }, { "path": "./packages/coverage-ios" + }, + { + "path": "./website" } ] } From 885aaa8135571d7bcdc27eff8031f9b55814a9d4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 20 May 2026 08:40:08 +0200 Subject: [PATCH 9/9] docs: document test context --- .../version-plan-1779259133000.md | 5 ++ website/src/docs/api/defining-tests.md | 79 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .nx/version-plans/version-plan-1779259133000.md diff --git a/.nx/version-plans/version-plan-1779259133000.md b/.nx/version-plans/version-plan-1779259133000.md new file mode 100644 index 00000000..6dd6a0fb --- /dev/null +++ b/.nx/version-plans/version-plan-1779259133000.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness test callbacks now consistently receive a `HarnessTestContext` in `test`, `it`, `beforeEach`, and `afterEach`, exposing task metadata, dynamic skipping with `context.skip(...)`, and per-test `onTestFinished` / `onTestFailed` lifecycle hooks. diff --git a/website/src/docs/api/defining-tests.md b/website/src/docs/api/defining-tests.md index a28c3ea1..495e75ac 100644 --- a/website/src/docs/api/defining-tests.md +++ b/website/src/docs/api/defining-tests.md @@ -5,7 +5,29 @@ This guide covers how to define and organize tests in Harness using test functio The following types are used in the type signatures below: ```typescript -type TestFn = () => void | Promise +type HarnessTaskContext = { + name: string + type: 'test' + mode: 'run' | 'skip' | 'todo' + file: { + name: string + } + suite: { + name: string + } +} + +type HarnessTestContext = { + task: HarnessTaskContext + onTestFailed: (fn: () => void | Promise) => void + onTestFinished: (fn: () => void | Promise) => void + skip: { + (note?: string): never + (condition: boolean, note?: string): void + } +} + +type TestFn = (context: HarnessTestContext) => void | Promise ``` When a test function returns a promise, the runner will wait until it is resolved to collect async expectations. If the promise is rejected, the test will fail. @@ -26,6 +48,37 @@ test('should work as expected', () => { }) ``` +Test callbacks always receive a `HarnessTestContext` object at runtime. You can ignore the parameter when you do not need it. + +```typescript +import { test, expect } from 'react-native-harness' + +test('can inspect task metadata', (context) => { + expect(context.task.type).toBe('test') + expect(context.task.name).toBe('can inspect task metadata') +}) +``` + +The context also lets you dynamically skip a test and register lifecycle callbacks that run after the test finishes or fails. + +```typescript +import { test } from 'react-native-harness' + +test('can skip dynamically', (context) => { + context.skip('Blocked by a missing backend fixture') +}) + +test('can react to the final test outcome', (context) => { + context.onTestFinished(() => { + cleanupTemporaryFiles() + }) + + context.onTestFailed(() => { + captureDebugLogs() + }) +}) +``` + ### test.skip - **Alias:** `it.skip` @@ -139,6 +192,22 @@ describe('user tests', () => { }) ``` +`beforeEach` receives the same `HarnessTestContext` object as the test callback, so you can inspect task metadata or dynamically skip from setup code. + +```typescript +import { describe, beforeEach, test } from 'react-native-harness' + +describe('user tests', () => { + beforeEach((context) => { + context.skip(context.task.name === 'requires seed data', 'Seed data missing') + }) + + test('requires seed data', (context) => { + // Test implementation + }) +}) +``` + ### afterEach Register a callback to be called after each one of the tests in the current context completes. @@ -158,10 +227,14 @@ describe('user tests', () => { }) ``` +`afterEach` also receives `HarnessTestContext`, which is useful for cleanup keyed to the current task. + ### beforeAll Register a callback to be called once before starting to run all tests in the current context. +Unlike `test`, `beforeEach`, and `afterEach`, `beforeAll` does not receive a test context because it runs outside any single test case. + ```typescript import { describe, test, beforeAll } from 'react-native-harness' @@ -181,6 +254,8 @@ describe('user tests', () => { Register a callback to be called once after all tests have run in the current context. +Like `beforeAll`, `afterAll` does not receive a test context. + ```typescript import { describe, test, afterAll } from 'react-native-harness' @@ -200,4 +275,4 @@ describe('user tests', () => { - All test functions (`test`, `describe`, lifecycle hooks) must be called within a `describe` block in Harness - Tests run synchronously by default - use `async/await` for asynchronous operations -- Import all testing functions from `react-native-harness` \ No newline at end of file +- Import all testing functions from `react-native-harness`