diff --git a/.nx/version-plans/version-plan-platform-filters.md b/.nx/version-plans/version-plan-platform-filters.md new file mode 100644 index 00000000..348f3d38 --- /dev/null +++ b/.nx/version-plans/version-plan-platform-filters.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +Harness test files can now opt into platform-specific execution by suffixing the file name with a known platform, while shared harness tests continue to run everywhere. When you run Harness for a specific runner, files for other known platforms are filtered out before Jest schedules them, so `*.ios.harness.*` and `*.android.harness.*` tests can live side by side without failing on the wrong platform. diff --git a/apps/playground/src/__tests__/only-android.android.harness.ts b/apps/playground/src/__tests__/only-android.android.harness.ts new file mode 100644 index 00000000..90fdae5a --- /dev/null +++ b/apps/playground/src/__tests__/only-android.android.harness.ts @@ -0,0 +1,8 @@ +import { Platform } from 'react-native'; +import { describe, expect, test } from 'react-native-harness'; + +describe('Android-only harness test', () => { + test('reports android platform', () => { + expect(Platform.OS).toBe('android'); + }); +}); diff --git a/apps/playground/src/__tests__/only-ios.ios.harness.ts b/apps/playground/src/__tests__/only-ios.ios.harness.ts new file mode 100644 index 00000000..a8ac5f44 --- /dev/null +++ b/apps/playground/src/__tests__/only-ios.ios.harness.ts @@ -0,0 +1,8 @@ +import { Platform } from 'react-native'; +import { describe, expect, test } from 'react-native-harness'; + +describe('iOS-only harness test', () => { + test('reports ios platform', () => { + expect(Platform.OS).toBe('ios'); + }); +}); diff --git a/packages/cli/src/__tests__/jest-platform-ignore-pattern.test.ts b/packages/cli/src/__tests__/jest-platform-ignore-pattern.test.ts new file mode 100644 index 00000000..2d4793b6 --- /dev/null +++ b/packages/cli/src/__tests__/jest-platform-ignore-pattern.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import type { Config } from '@react-native-harness/config'; +import { + addJestPlatformIgnorePatternArg, + createPlatformTestPathIgnorePattern, +} from '../jest-platform-ignore-pattern.js'; + +const makeConfig = (): Config => ({ + entryPoint: 'index.js', + appRegistryComponentName: 'App', + defaultRunner: 'ios', + runners: [ + { + name: 'ios', + platformId: 'ios', + runner: '/virtual/ios-runner.js', + config: {}, + }, + { + name: 'android', + platformId: 'android', + runner: '/virtual/android-runner.js', + config: {}, + }, + { + name: 'web', + platformId: 'web', + runner: '/virtual/web-runner.js', + config: {}, + }, + ], + plugins: [], + metroPort: 8081, + webSocketPort: undefined, + bridgeTimeout: 60000, + platformReadyTimeout: 300000, + bundleStartTimeout: 60000, + maxAppRestarts: 2, + resetEnvironmentBetweenTestFiles: true, + unstable__skipAlreadyIncludedModules: false, + unstable__enableMetroCache: false, + permissions: false, + detectNativeCrashes: true, + crashDetectionInterval: 500, + disableViewFlattening: false, + forwardClientLogs: false, +}); + +describe('createPlatformTestPathIgnorePattern', () => { + it('matches harness test files for other known platforms only', () => { + const pattern = createPlatformTestPathIgnorePattern({ + knownPlatformIds: ['android', 'ios', 'web'], + platformId: 'ios', + }); + + expect(pattern).toBe('\\.(android|web)\\.harness\\.(?:[mc]?[jt]sx?)$'); + if (pattern == null) { + throw new Error('Expected platform ignore pattern to be generated'); + } + + const regex = new RegExp(pattern); + expect(regex.test('/tests/only-android.android.harness.ts')).toBe(true); + expect(regex.test('/tests/browser.web.harness.tsx')).toBe(true); + expect(regex.test('/tests/only-ios.ios.harness.ts')).toBe(false); + expect(regex.test('/tests/shared.harness.ts')).toBe(false); + expect(regex.test('/tests/custom.foo.harness.ts')).toBe(false); + }); +}); + +describe('addJestPlatformIgnorePatternArg', () => { + it('adds a Jest testPathIgnorePatterns arg for the selected runner platform', async () => { + const argv = ['node', 'harness', '--harnessRunner', 'android']; + + await expect( + addJestPlatformIgnorePatternArg({ + argv, + cwd: '/tmp/project', + loadConfig: async () => ({ + projectRoot: '/tmp/project', + config: makeConfig(), + }), + }), + ).resolves.toBe(true); + + expect(argv).toEqual([ + 'node', + 'harness', + '--harnessRunner', + 'android', + '--testPathIgnorePatterns', + '\\.(ios|web)\\.harness\\.(?:[mc]?[jt]sx?)$', + ]); + }); + + it('uses the default runner when no runner CLI arg is provided', async () => { + const argv = ['node', 'harness']; + + await expect( + addJestPlatformIgnorePatternArg({ + argv, + cwd: '/tmp/project', + loadConfig: async () => ({ + projectRoot: '/tmp/project', + config: makeConfig(), + }), + }), + ).resolves.toBe(true); + + expect(argv.at(-2)).toBe('--testPathIgnorePatterns'); + expect(argv.at(-1)).toBe('\\.(android|web)\\.harness\\.(?:[mc]?[jt]sx?)$'); + }); +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cd8da9c7..af488bd4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { run, yargsOptions } from 'jest-cli'; import { getConfig } from '@react-native-harness/config'; import { runInitWizard } from './wizard/index.js'; import { runPlatformCommand } from './platform-commands.js'; +import { addJestPlatformIgnorePatternArg } from './jest-platform-ignore-pattern.js'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -237,6 +238,11 @@ const main = async () => { } } + await addJestPlatformIgnorePatternArg({ + argv: process.argv, + cwd: process.cwd(), + }); + await checkForOldConfig(); run(); }; diff --git a/packages/cli/src/jest-platform-ignore-pattern.ts b/packages/cli/src/jest-platform-ignore-pattern.ts new file mode 100644 index 00000000..757527be --- /dev/null +++ b/packages/cli/src/jest-platform-ignore-pattern.ts @@ -0,0 +1,87 @@ +import type { Config } from '@react-native-harness/config'; +import { getConfig } from '@react-native-harness/config'; + +type HarnessConfigResult = Awaited>; + +type AddPlatformIgnorePatternOptions = { + argv: string[]; + cwd: string; + loadConfig?: (cwd: string) => Promise; +}; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const getCliFlagValue = (argv: string[], flagName: string): string | undefined => { + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + const prefix = `--${flagName}=`; + + if (arg.startsWith(prefix)) { + return arg.slice(prefix.length); + } + + if (arg === `--${flagName}`) { + return argv[index + 1]; + } + } + + return undefined; +}; + +export const createPlatformTestPathIgnorePattern = ({ + knownPlatformIds, + platformId, +}: { + knownPlatformIds: string[]; + platformId: string; +}): string | null => { + const ignoredPlatformIds = knownPlatformIds + .filter((knownPlatformId) => knownPlatformId !== platformId) + .map(escapeRegExp); + + if (ignoredPlatformIds.length === 0) { + return null; + } + + return `\\.(${ignoredPlatformIds.join('|')})\\.harness\\.(?:[mc]?[jt]sx?)$`; +}; + +export const addJestPlatformIgnorePatternArg = async ({ + argv, + cwd, + loadConfig = getConfig, +}: AddPlatformIgnorePatternOptions): Promise => { + let configResult: HarnessConfigResult; + try { + configResult = await loadConfig(cwd); + } catch { + return false; + } + + const selectedRunnerName = + getCliFlagValue(argv, 'harnessRunner') ?? configResult.config.defaultRunner; + if (!selectedRunnerName) { + return false; + } + + const selectedRunner = configResult.config.runners.find( + (runner: Config['runners'][number]) => runner.name === selectedRunnerName, + ); + if (!selectedRunner) { + return false; + } + + const ignorePattern = createPlatformTestPathIgnorePattern({ + knownPlatformIds: [ + ...new Set(configResult.config.runners.map((runner) => runner.platformId)), + ], + platformId: selectedRunner.platformId, + }); + if (!ignorePattern) { + return false; + } + + argv.push('--testPathIgnorePatterns', ignorePattern); + return true; +}; diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 0a40ed1a..632ce142 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -98,8 +98,20 @@ const makeSession = (overrides: Partial = {}): HarnessSession => host: undefined, resetEnvironmentBetweenTestFiles: false, detectNativeCrashes: true, + runners: [ + { platformId: 'android', name: 'android' }, + { platformId: 'ios', name: 'ios' }, + ], } as HarnessSession['config'], - context: {} as HarnessSession['context'], + context: { + platform: { + platformId: 'android', + name: 'android', + runner: '/virtual/android-runner.js', + cli: '/virtual/android-cli.js', + config: {}, + }, + } as HarnessSession['context'], ensureAppReady: vi.fn(resolveUndefined), runTestFile: vi.fn(async () => makeHarnessResult()), restartApp: vi.fn(resolveUndefined), @@ -431,6 +443,10 @@ describe('executeRun', () => { metroPort: 8081, resetEnvironmentBetweenTestFiles: true, detectNativeCrashes: false, + runners: [ + { platformId: 'android', name: 'android' }, + { platformId: 'ios', name: 'ios' }, + ], } as HarnessSession['config'], }); @@ -446,4 +462,65 @@ describe('executeRun', () => { expect(session.restartApp).toHaveBeenCalledTimes(2); }); }); + + describe('platform-specific test files', () => { + it('skips files for other platforms without running them on the device', async () => { + const session = makeSession(); + const { emitEvent, calls } = makeEmitEvent(); + + await executeRun( + session, + [ + makeTest('/project/smoke.harness.ts'), + makeTest('/project/kotlin.android.harness.ts'), + makeTest('/project/swift.ios.harness.ts'), + ], + makeWatcher(), + emitEvent, + makeGlobalConfig(), + ); + + expect(mockRunHarnessTestFile).toHaveBeenCalledTimes(2); + expect(mockRunHarnessTestFile).toHaveBeenCalledWith( + expect.objectContaining({ testPath: '/project/smoke.harness.ts' }), + ); + expect(mockRunHarnessTestFile).toHaveBeenCalledWith( + expect.objectContaining({ testPath: '/project/kotlin.android.harness.ts' }), + ); + expect(session.ensureAppReady).toHaveBeenCalledTimes(2); + expect(calls).toContainEqual([ + 'test-file-success', + expect.objectContaining({ path: '/project/swift.ios.harness.ts' }), + expect.objectContaining({ skipped: true }), + ]); + }); + + it('does not restart the app for skipped platform-specific files', async () => { + const session = makeSession({ + config: { + metroPort: 8081, + resetEnvironmentBetweenTestFiles: true, + detectNativeCrashes: false, + runners: [ + { platformId: 'android', name: 'android' }, + { platformId: 'ios', name: 'ios' }, + ], + } as HarnessSession['config'], + }); + + await executeRun( + session, + [ + makeTest('/project/swift.ios.harness.ts'), + makeTest('/project/kotlin.android.harness.ts'), + ], + makeWatcher(), + vi.fn(), + makeGlobalConfig(), + ); + + expect(session.restartApp).not.toHaveBeenCalled(); + expect(mockRunHarnessTestFile).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/jest/src/__tests__/test-file-platform-filter.test.ts b/packages/jest/src/__tests__/test-file-platform-filter.test.ts new file mode 100644 index 00000000..ef2c492f --- /dev/null +++ b/packages/jest/src/__tests__/test-file-platform-filter.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { + createPlatformSkippedTestResult, + getHarnessTestFilePlatform, + shouldRunHarnessTestFile, +} from '../test-file-platform-filter.js'; + +const knownPlatformIds = new Set(['android', 'ios', 'web', 'vega']); + +describe('getHarnessTestFilePlatform', () => { + it('returns null for shared harness test files', () => { + expect(getHarnessTestFilePlatform('/tests/smoke.harness.ts', knownPlatformIds)).toBeNull(); + expect(getHarnessTestFilePlatform('/tests/smoke.harness.tsx', knownPlatformIds)).toBeNull(); + expect(getHarnessTestFilePlatform('/tests/smoke.harness.mjs', knownPlatformIds)).toBeNull(); + }); + + it('returns the platform id for platform-specific harness test files', () => { + expect( + getHarnessTestFilePlatform('/tests/kotlin.android.harness.ts', knownPlatformIds), + ).toBe('android'); + expect( + getHarnessTestFilePlatform('/tests/swift.ios.harness.ts', knownPlatformIds), + ).toBe('ios'); + expect( + getHarnessTestFilePlatform('/tests/browser.web.harness.ts', knownPlatformIds), + ).toBe('web'); + }); + + it('returns null when the segment before .harness is not a known platform id', () => { + expect( + getHarnessTestFilePlatform('/tests/smoke.harness.ts', knownPlatformIds), + ).toBeNull(); + expect( + getHarnessTestFilePlatform('/tests/custom.foo.harness.ts', knownPlatformIds), + ).toBeNull(); + }); +}); + +describe('shouldRunHarnessTestFile', () => { + it('runs shared harness test files on every platform', () => { + expect( + shouldRunHarnessTestFile('/tests/smoke.harness.ts', 'android', knownPlatformIds), + ).toBe(true); + expect( + shouldRunHarnessTestFile('/tests/smoke.harness.ts', 'ios', knownPlatformIds), + ).toBe(true); + }); + + it('runs platform-specific files only on the matching platform', () => { + expect( + shouldRunHarnessTestFile('/tests/kotlin.android.harness.ts', 'android', knownPlatformIds), + ).toBe(true); + expect( + shouldRunHarnessTestFile('/tests/kotlin.android.harness.ts', 'ios', knownPlatformIds), + ).toBe(false); + expect( + shouldRunHarnessTestFile('/tests/swift.ios.harness.ts', 'ios', knownPlatformIds), + ).toBe(true); + expect( + shouldRunHarnessTestFile('/tests/swift.ios.harness.ts', 'android', knownPlatformIds), + ).toBe(false); + }); +}); + +describe('createPlatformSkippedTestResult', () => { + it('marks the entire test file as skipped', () => { + const result = createPlatformSkippedTestResult('/tests/swift.ios.harness.ts'); + + expect(result.skipped).toBe(true); + expect(result.numPassingTests).toBe(0); + expect(result.numFailingTests).toBe(0); + expect(result.numPendingTests).toBe(1); + expect(result.testResults).toHaveLength(1); + expect(result.testResults[0]).toEqual( + expect.objectContaining({ + status: 'skipped', + title: 'swift.ios.harness.ts', + fullName: 'swift.ios.harness.ts', + }), + ); + expect(result.testFilePath).toBe('/tests/swift.ios.harness.ts'); + }); +}); diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 1ecdb7b9..16d7383a 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -22,6 +22,10 @@ import type { TestRunnerTestFinishedEvent, TestRunnerTestStartedEvent, } from '@react-native-harness/bridge'; +import { + createPlatformSkippedTestResult, + shouldRunHarnessTestFile, +} from './test-file-platform-filter.js'; type EmitTestEvent = ( eventName: Name, @@ -163,6 +167,10 @@ export const executeRun = async ( }); const shouldResetEnv = session.config.resetEnvironmentBetweenTestFiles; + const platformId = session.context.platform.platformId; + const knownPlatformIds = new Set( + session.config.runners.map((runner) => runner.platformId), + ); let isFirstTest = true; let runError: unknown; @@ -191,6 +199,34 @@ export const executeRun = async ( await session.callHook('test-file:started', { runId, file: relativeTestPath }); + if ( + !shouldRunHarnessTestFile(test.path, platformId, knownPlatformIds) + ) { + try { + await emitEvent('test-file-start', test); + const skippedResult = createPlatformSkippedTestResult(test.path); + applyJestResultToSummary(summary, skippedResult); + updateRunState(); + await emitTestFileFinished({ + status: 'skipped', + duration: Date.now() - fileStartedAt, + result: null, + }); + await emitEvent('test-file-success', test, skippedResult); + } catch (err) { + if (!emittedTestFileFinished) { + await emitTestFileFinished({ + status: 'failed', + duration: Date.now() - fileStartedAt, + result: null, + }); + } + updateRunState({ error: err }); + await emitEvent('test-file-failure', test, buildTestFailure(err)); + } + continue; + } + try { if (shouldResetEnv && !isFirstTest) { await session.restartApp(test.path); diff --git a/packages/jest/src/test-file-platform-filter.ts b/packages/jest/src/test-file-platform-filter.ts new file mode 100644 index 00000000..0bad54d9 --- /dev/null +++ b/packages/jest/src/test-file-platform-filter.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; +import type { TestResult as JestTestResult } from '@jest/test-result'; +import { toTestResult } from './toTestResult.js'; + +const PLATFORM_SPECIFIC_HARNESS_FILE = + /\.([^.]+)\.harness\.(?:[mc]?[jt]sx?)$/; + +export const getHarnessTestFilePlatform = ( + testPath: string, + knownPlatformIds: ReadonlySet, +): string | null => { + const match = path.basename(testPath).match(PLATFORM_SPECIFIC_HARNESS_FILE); + if (!match) { + return null; + } + + const candidate = match[1]; + return knownPlatformIds.has(candidate) ? candidate : null; +}; + +export const shouldRunHarnessTestFile = ( + testPath: string, + platformId: string, + knownPlatformIds: ReadonlySet, +): boolean => { + const filePlatform = getHarnessTestFilePlatform(testPath, knownPlatformIds); + return filePlatform == null || filePlatform === platformId; +}; + +export const createPlatformSkippedTestResult = ( + testPath: string, +): JestTestResult => { + const now = Date.now(); + const title = path.basename(testPath); + + return toTestResult({ + stats: { + failures: 0, + passes: 0, + pending: 1, + todo: 0, + start: now, + end: now, + }, + skipped: true, + errorMessage: null, + tests: [ + { + status: 'skipped', + title, + fullName: title, + testPath, + }, + ], + jestTestPath: testPath, + }); +}; diff --git a/website/src/docs/api/defining-tests.md b/website/src/docs/api/defining-tests.md index 495e75ac..e5fbd23a 100644 --- a/website/src/docs/api/defining-tests.md +++ b/website/src/docs/api/defining-tests.md @@ -32,6 +32,20 @@ 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. +## Platform-Specific Test Files + +Harness test files normally run on every selected runner. If a file should only run on one platform, add the runner platform ID before `.harness`: + +```text +src/__tests__/only-ios.ios.harness.ts +src/__tests__/only-android.android.harness.ts +src/__tests__/browser.web.harness.ts +``` + +When you run Harness with `--harnessRunner ios`, files such as `*.android.harness.ts` and `*.web.harness.ts` are filtered out before Jest schedules them. Shared files such as `smoke.harness.ts` still run on every platform. + +The platform segment is recognized only when it matches a `platformId` from one of your configured runners. Unknown segments are treated as part of the regular file name, so `custom.foo.harness.ts` still behaves like a shared Harness test unless `foo` is a configured platform ID. + ## Test Functions ### test diff --git a/website/src/docs/getting-started/quick-start.mdx b/website/src/docs/getting-started/quick-start.mdx index 9343f377..18f6d2ba 100644 --- a/website/src/docs/getting-started/quick-start.mdx +++ b/website/src/docs/getting-started/quick-start.mdx @@ -181,6 +181,8 @@ describe('My First Harness Test', () => { }); ``` +To keep platform-specific checks next to shared tests, add the runner platform ID before `.harness`. For example, `only-ios.ios.harness.ts` runs only when the selected runner has `platformId: 'ios'`, while `only-android.android.harness.ts` runs only for Android runners. Regular files like `MyComponent.harness.ts` continue to run on every platform. + ## Available Testing APIs React Native Harness provides Jest-compatible APIs through `react-native-harness`.