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/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.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..94f27886 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..e96e3955 --- /dev/null +++ b/packages/bridge/src/shared/test-context.ts @@ -0,0 +1,21 @@ +export type HarnessTaskContext = { + name: string; + type: 'test'; + mode: 'run' | 'skip' | 'todo'; + file: { + name: string; + }; + suite: { + name: string; + }; +}; + +export type HarnessTestContext = { + task: HarnessTaskContext; + onTestFailed: (fn: () => void | Promise) => void; + onTestFinished: (fn: () => void | Promise) => void; + skip: { + (note?: string): never; + (condition: boolean, note?: string): void; + }; +}; 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 new file mode 100644 index 00000000..89289a47 --- /dev/null +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -0,0 +1,483 @@ +import { + 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 () => { + return { + getCodeFrame: vi.fn(async () => null), + }; +}); + +const getTask = (context: HarnessTestContext) => { + return context.task; +}; + +const getTaskContext = (context: HarnessTestContext) => { + return context; +}; + +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: HarnessTestContext) => { + observedTasks.push({ source: 'beforeEach', task: getTask(context) }); + }); + + afterEach((context: HarnessTestContext) => { + observedTasks.push({ source: 'afterEach', task: getTask(context) }); + }); + + harnessIt('exposes task metadata', (context: HarnessTestContext) => { + observedTasks.push({ source: 'test', task: getTask(context) }); + }); + }); + }, '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(); + } + }); + + 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', (context: HarnessTestContext) => { + const { skip } = getTaskContext(context); + + 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', (context: HarnessTestContext) => { + const { skip } = getTaskContext(context); + + 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(); + } + }); + + 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', (context: HarnessTestContext) => { + const { onTestFinished } = getTaskContext(context); + + 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', + (context: HarnessTestContext) => { + const { onTestFinished, skip } = getTaskContext(context); + + 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', + (context: HarnessTestContext) => { + const { onTestFinished } = getTaskContext(context); + + 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(); + } + }); + + 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', (context: HarnessTestContext) => { + const { onTestFailed } = getTaskContext(context); + + 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', + (context: HarnessTestContext) => { + const { onTestFailed, skip } = getTaskContext(context); + + 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', + (context: HarnessTestContext) => { + const { onTestFailed } = getTaskContext(context); + + 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/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..081700f8 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..69b47a6a 100644 --- a/packages/runtime/src/runner/hooks.ts +++ b/packages/runtime/src/runner/hooks.ts @@ -1,12 +1,33 @@ -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)[] = []; + 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 @@ -16,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); } } @@ -41,11 +54,22 @@ const collectInheritedHooks = ( export const runHooks = async ( suite: TestSuite, - hookType: HookType + 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(); + await hook(context as ActiveTestContext); } }; diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 7dc5c008..c1f2ec25 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,14 @@ 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'; +import { + createTestContext, + createTestLifecycleState, + isSkipTestError, + runOnTestFailed, + runOnTestFinished, +} from './test-context.js'; declare global { var HARNESS_TEST_PATH: string; @@ -23,6 +31,27 @@ 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 lifecycleState = createTestLifecycleState(); + const activeTestContext: ActiveTestContext = createTestContext( + task, + lifecycleState, + ); // Emit test-started event context.events.emit({ @@ -78,16 +107,50 @@ const runTest = async ( setCurrentExpectTestState(expectTestState); try { - // Run all beforeEach hooks from the current suite and its parents - await runHooks(suite, 'beforeEach'); - - // Run the actual test - await test.fn(); - - // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach'); + 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; + + await runOnTestFinished(lifecycleState); + + 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); + await runOnTestFinished(lifecycleState); } finally { setCurrentExpectTestState(undefined); } @@ -112,6 +175,9 @@ const runTest = async ( return result; } catch (error) { + await runOnTestFailed(lifecycleState); + 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 new file mode 100644 index 00000000..42edf69d --- /dev/null +++ b/packages/runtime/src/runner/test-context.ts @@ -0,0 +1,84 @@ +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>; +}; + +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']; +}; + +const createOnTestFinished = (state: TestLifecycleState) => { + return (fn: () => void | Promise): void => { + state.onTestFinished.push(fn); + }; +}; + +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 => { + for (let i = state.onTestFinished.length - 1; i >= 0; i--) { + await state.onTestFinished[i](); + } +}; + +export const createTestContext = ( + task: HarnessTaskContext, + state: TestLifecycleState, +): ActiveTestContext => { + return { + task, + onTestFailed: createOnTestFailed(state), + onTestFinished: createOnTestFinished(state), + skip: createSkip(), + }; +}; 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; 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', + ), }, }, })); 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" } ] } 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`