diff --git a/.nx/version-plans/version-plan-1787953392799.md b/.nx/version-plans/version-plan-1787953392799.md new file mode 100644 index 00000000..099108e5 --- /dev/null +++ b/.nx/version-plans/version-plan-1787953392799.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +The harness now runs on a Windows host and recognizes React Native Windows as a device platform: ESM (`rn-harness.config.mjs`) configs load correctly when the harness process runs on Windows, and an app reporting `Platform.OS === 'windows'` completes the bridge handshake instead of failing with "Unsupported platform". diff --git a/.nx/version-plans/version-plan-1787960210915.md b/.nx/version-plans/version-plan-1787960210915.md new file mode 100644 index 00000000..5c89ab5b --- /dev/null +++ b/.nx/version-plans/version-plan-1787960210915.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +The `@react-native-harness/platform-windows` package now supplies its own Metro wiring through the `metroConfigEnhancer` hook: the `react-native` -> `react-native-windows` resolver redirect, the `windows` and `native` `resolver.platforms` entries, and React Native Windows' `InitializeCore`. A `windowsPlatform()` runner no longer needs any of this hand-added to `metro.config.js`, and `@react-native-harness/bundler-metro` no longer reads `@react-native-community/cli-config` to detect out-of-tree platforms. iOS and Android runs are unaffected. diff --git a/.nx/version-plans/version-plan-1787965767199.md b/.nx/version-plans/version-plan-1787965767199.md new file mode 100644 index 00000000..c47a11f2 --- /dev/null +++ b/.nx/version-plans/version-plan-1787965767199.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +New `@react-native-harness/platform-windows` package: run harness tests against a deployed React Native Windows app. Add `windowsPlatform({ name, packageName })` to `rn-harness.config.mjs` — the runner resolves the package family name via `Get-AppxPackage`, shell-activates the app by its AUMID, and tracks it by process name. Requires the app to be deployed first (`react-native run-windows`). diff --git a/.nx/version-plans/version-plan-1787973049799.md b/.nx/version-plans/version-plan-1787973049799.md new file mode 100644 index 00000000..13d58fd0 --- /dev/null +++ b/.nx/version-plans/version-plan-1787973049799.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +A resource-lock heartbeat refresh that fails to write (for example the owner file racing a concurrent release, or a transient filesystem error) is now swallowed instead of surfacing as an unhandled rejection — the lock simply goes stale and is reclaimed, as it already would if the refresh were missed. diff --git a/.nx/version-plans/version-plan-1787973282091.md b/.nx/version-plans/version-plan-1787973282091.md new file mode 100644 index 00000000..f024c3b8 --- /dev/null +++ b/.nx/version-plans/version-plan-1787973282091.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +The resource lock a platform runner defines via `getResourceLockKey` is now honored. Concurrent Harness runs that target the same platform but different devices — two iOS simulators, or an emulator and a physical device — no longer queue behind each other; only runs that share a device wait. Previously the key was silently dropped by config validation and every run of a platform serialized on `:`. diff --git a/action.yml b/action.yml index 1f12e196..30c75df6 100644 --- a/action.yml +++ b/action.yml @@ -1,12 +1,15 @@ name: React Native Harness -description: Run React Native Harness tests on iOS, Android or Web +description: Run React Native Harness tests on iOS, Android, Web or Windows inputs: runner: description: The runner to use (must match a runner name defined in your harness config) required: true type: string app: - description: The path to the app (.app for iOS, .apk for Android). Not required for web. + description: >- + The path to the built app (.app for iOS, .apk for Android). Not required + for web, or for Windows (deploy the app with `react-native run-windows` + before this action runs). required: false type: string projectRoot: @@ -147,7 +150,9 @@ runs: run: | ${{ steps.detect-pm.outputs.runner }}react-native-harness ci load-config - name: Verify native app input - if: fromJson(steps.load-config.outputs.config).platformId != 'web' + # Windows, like web, takes no `app` path: the harness launches an + # already-deployed MSIX package by its identity. + if: ${{ fromJson(steps.load-config.outputs.config).platformId != 'web' && fromJson(steps.load-config.outputs.config).platformId != 'windows' }} shell: bash run: | if [ -z "${{ inputs.app }}" ]; then @@ -264,6 +269,10 @@ runs: if: fromJson(steps.load-config.outputs.config).platformId == 'web' shell: bash run: npx playwright install --with-deps chromium + # ── Windows ────────────────────────────────────────────────────────────── + # Nothing to set up here: run the workflow on a `windows-*` runner and + # deploy the app with `react-native run-windows --no-launch` in an earlier + # step. The harness launches the deployed package and tracks its process. # ── Shared ─────────────────────────────────────────────────────────────── - name: Run E2E tests @@ -277,7 +286,10 @@ runs: HARNESS_APP_PATH: ${{ inputs.app }} HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | - export HARNESS_PROJECT_ROOT="$PWD" + # `pwd -W` prints the native Windows path under Git Bash, so child + # processes get `D:/...` rather than an unusable `/d/...` msys path; + # it fails on Linux/macOS, where plain `pwd` is already correct. + export HARNESS_PROJECT_ROOT="$(pwd -W 2>/dev/null || pwd)" set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index acbbae5c..64dc802e 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -113,7 +113,7 @@ export type { } from './shared/bundler.js'; export type DeviceDescriptor = { - platform: 'ios' | 'android' | 'vega' | 'web'; + platform: 'ios' | 'android' | 'vega' | 'web' | 'windows'; manufacturer: string; model: string; osVersion: string; diff --git a/packages/bundler-metro/src/__tests__/metro-block-list.test.ts b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts index fe64a861..6c98f1cd 100644 --- a/packages/bundler-metro/src/__tests__/metro-block-list.test.ts +++ b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts @@ -22,6 +22,11 @@ const withBlockList = ( const HARNESS_CACHE_ROOT = '/p/.harness/cache'; +// Metro's `exclusionList` rewrites `/` in its patterns to `path.sep`, so a +// blockList inherited from it only matches paths in the host OS's separator. +// The harness's own patterns match either separator; these need the switch. +const sys = (posixPath: string) => posixPath.split('/').join(path.sep); + const getBlockList = ( blockList: NonNullable['blockList'] ) => getHarnessBlockList(withBlockList(blockList), HARNESS_CACHE_ROOT); @@ -148,7 +153,9 @@ describe('getHarnessBlockList', () => { const { blockList, dropped } = getBlockList(exclusionList()); expect(dropped).toEqual([]); - expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false); + expect(blockList.test(sys('/p/src/__tests__/smoke.harness.ts'))).toBe( + false + ); }); it("keeps a project's own exclusions while still crawling tests", () => { @@ -159,9 +166,11 @@ describe('getHarnessBlockList', () => { ); expect(dropped).toEqual([]); - expect(blockList.test('/p/ios/build/Release/x.json')).toBe(true); - expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false); - expect(blockList.test(getHarnessManifestPath('/p'))).toBe(false); + expect(blockList.test(sys('/p/ios/build/Release/x.json'))).toBe(true); + expect(blockList.test(sys('/p/src/__tests__/smoke.harness.ts'))).toBe( + false + ); + expect(blockList.test(getHarnessManifestPath(sys('/p')))).toBe(false); }); it('keeps tests crawlable even inside an otherwise excluded directory', () => { @@ -187,7 +196,7 @@ describe('getHarnessBlockList', () => { '/p/vendor/lib.js', '/p/src/app.tsx', '/p/ios/build/__tests__/nested.harness.ts', - ]; + ].map(sys); for (const pattern of patterns) { const { blockList } = getBlockList(pattern); diff --git a/packages/bundler-metro/src/__tests__/paths.test.ts b/packages/bundler-metro/src/__tests__/paths.test.ts index 3e7539b4..69e8e456 100644 --- a/packages/bundler-metro/src/__tests__/paths.test.ts +++ b/packages/bundler-metro/src/__tests__/paths.test.ts @@ -4,7 +4,10 @@ import { getHarnessManifestPath, getHarnessRootPath } from '../paths.js'; describe('bundler metro paths', () => { it('resolves the harness root under the project root', () => { - const projectRoot = '/tmp/some-project'; + // An absolute path on the host OS -- `/tmp/...` is not absolute on + // Windows, so `path.resolve` would prepend the cwd drive and the + // assertions below would never match. + const projectRoot = path.resolve('some-project'); expect(getHarnessRootPath(projectRoot)).toBe( path.join(projectRoot, '.harness') diff --git a/packages/bundler-metro/src/index.ts b/packages/bundler-metro/src/index.ts index de75865b..f9a8d7d9 100644 --- a/packages/bundler-metro/src/index.ts +++ b/packages/bundler-metro/src/index.ts @@ -1,5 +1,7 @@ export { getMetroInstance } from './factory.js'; export type { + MetroConfigEnhancer, + MetroConfigEnhancerContext, MetroInstance, MetroFactory, MetroOptions, diff --git a/packages/cache/src/__tests__/boundary.test.ts b/packages/cache/src/__tests__/boundary.test.ts index e8779b54..5398c8a6 100644 --- a/packages/cache/src/__tests__/boundary.test.ts +++ b/packages/cache/src/__tests__/boundary.test.ts @@ -79,7 +79,10 @@ describe('cache path boundary', () => { } for (const file of collectSourceFiles(srcDir)) { - const relativePath = path.relative(packagesRoot, file); + const relativePath = path + .relative(packagesRoot, file) + .split(path.sep) + .join('/'); if (ALLOWLIST.has(relativePath)) { continue; } diff --git a/packages/cli/src/ci/workspace-root.ts b/packages/cli/src/ci/workspace-root.ts index 4e199c76..4c4ef94d 100644 --- a/packages/cli/src/ci/workspace-root.ts +++ b/packages/cli/src/ci/workspace-root.ts @@ -34,6 +34,11 @@ export const resolveProjectRoot = ( * GITHUB_OUTPUT is relative to the workspace root, matching what * downstream non-bash steps (actions/cache, actions/upload-artifact, * hashFiles(...)) resolve paths against. + * + * Emitted with forward slashes so the value is stable across runner OSes: + * `actions/cache` globs, `hashFiles()`, and a bash `working-directory` all + * accept `/` on Windows, whereas a raw `path.relative` result would be + * `apps\foo` there. */ export const relativeToWorkspaceRoot = (target: string): string => - path.relative(getWorkspaceRoot(), target) || '.'; + (path.relative(getWorkspaceRoot(), target) || '.').split(path.sep).join('/'); diff --git a/packages/config/src/__tests__/reader.test.ts b/packages/config/src/__tests__/reader.test.ts new file mode 100644 index 00000000..1e2ca63e --- /dev/null +++ b/packages/config/src/__tests__/reader.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getConfig } from '../reader.js'; + +const CONFIG_BODY = { + entryPoint: './index.js', + appRegistryComponentName: 'App', + runners: [ + { + name: 'test-runner', + config: {}, + runner: 'test-runner', + platformId: 'test-platform', + }, + ], +}; + +let projectDir: string; + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-harness-reader-')); +}); + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); +}); + +describe('getConfig', () => { + it('loads an ESM (.mjs) config via a file:// URL', async () => { + // A bare absolute path passed to dynamic import() is rejected on Windows + // (ERR_UNSUPPORTED_ESM_URL_SCHEME because `C:` reads as a URL scheme); the + // reader must convert it with pathToFileURL first. This exercises that path + // on every OS and regression-guards it on Windows. + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.mjs'), + `export default ${JSON.stringify(CONFIG_BODY)};\n` + ); + + const { config, projectRoot } = await getConfig(projectDir); + + expect(config.entryPoint).toBe('./index.js'); + expect(config.runners).toHaveLength(1); + expect(projectRoot).toBe(projectDir); + }); + + it('loads a CommonJS (.js) config', async () => { + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.js'), + `module.exports = ${JSON.stringify(CONFIG_BODY)};\n` + ); + + const { config } = await getConfig(projectDir); + + expect(config.appRegistryComponentName).toBe('App'); + }); + + it('loads a JSON config', async () => { + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.json'), + JSON.stringify(CONFIG_BODY) + ); + + const { config } = await getConfig(projectDir); + + expect(config.entryPoint).toBe('./index.js'); + }); + + it('walks up to a parent directory to find the config', async () => { + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.mjs'), + `export default ${JSON.stringify(CONFIG_BODY)};\n` + ); + const nested = path.join(projectDir, 'a', 'b'); + fs.mkdirSync(nested, { recursive: true }); + + const { projectRoot } = await getConfig(nested); + + expect(projectRoot).toBe(projectDir); + }); +}); diff --git a/packages/config/src/__tests__/runner-schema.test.ts b/packages/config/src/__tests__/runner-schema.test.ts index d34ebdd7..1d068a9e 100644 --- a/packages/config/src/__tests__/runner-schema.test.ts +++ b/packages/config/src/__tests__/runner-schema.test.ts @@ -14,6 +14,47 @@ const runner = { }; describe('ConfigSchema runner', () => { + it('preserves a platform-provided getResourceLockKey', () => { + const getResourceLockKey = () => 'ios:iPhone 16 Pro:18.0'; + + const parsed = ConfigSchema.parse({ + ...baseConfig, + runners: [{ ...runner, getResourceLockKey }], + }); + + expect(parsed.runners[0]?.getResourceLockKey?.()).toBe( + 'ios:iPhone 16 Pro:18.0' + ); + }); + + it('accepts an async getResourceLockKey', async () => { + const parsed = ConfigSchema.parse({ + ...baseConfig, + runners: [ + { ...runner, getResourceLockKey: async () => 'android:Pixel_8' }, + ], + }); + + await expect(parsed.runners[0]?.getResourceLockKey?.()).resolves.toBe( + 'android:Pixel_8' + ); + }); + + it('is optional', () => { + const parsed = ConfigSchema.parse({ ...baseConfig, runners: [runner] }); + + expect(parsed.runners[0]?.getResourceLockKey).toBeUndefined(); + }); + + it('rejects a non-function getResourceLockKey', () => { + expect(() => + ConfigSchema.parse({ + ...baseConfig, + runners: [{ ...runner, getResourceLockKey: 'ios:lock' }], + }) + ).toThrow(); + }); + it('preserves a platform-provided metroConfigEnhancer path', () => { const parsed = ConfigSchema.parse({ ...baseConfig, diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index 83183787..1a3c5220 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -6,6 +6,7 @@ import { } from './errors.js'; import path from 'node:path'; import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; import { ZodError } from 'zod'; @@ -28,7 +29,11 @@ const importUp = async ( try { if (ext === '.mjs') { - rawConfig = await import(filePathWithExt).then( + // A dynamic import() of an absolute path only accepts a file:// URL. + // On POSIX the bare path happens to work; on Windows it is read as a + // URL and `C:` is rejected as an unknown scheme + // (ERR_UNSUPPORTED_ESM_URL_SCHEME). pathToFileURL normalizes both. + rawConfig = await import(pathToFileURL(filePathWithExt).href).then( (module) => module.default ); } else { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 346afdc4..965c6f53 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -23,6 +23,15 @@ const RunnerSchema = z.object({ // imported and run by the bundler while it composes the config. metroConfigEnhancer: z.string().optional(), platformId: z.string(), + // Set by the platform factories (`HarnessPlatform.getResourceLockKey`) to + // scope the run's resource lock — e.g. per emulator/simulator/device rather + // than per platform. A bare `z.object()` strips unknown keys, so without + // this the harness always fell back to `${platformId}:${name}`. + getResourceLockKey: z + .function() + .args() + .returns(z.union([z.string(), z.promise(z.string())])) + .optional(), }); type AnyHarnessPlugin = HarnessPlugin; diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 12966f90..3c234f1d 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { Config, Test, TestWatcher } from 'jest-runner'; import type { TestResult as JestTestResult } from '@jest/test-result'; @@ -221,7 +222,11 @@ describe('executeRun', () => { expect(runEntry).toMatchObject({ status: 'ok', attrs: { status: 'passed' } }); expect(runEntry?.attrs?.runId).toBeTypeOf('string'); - expect(fileEntry).toMatchObject({ status: 'ok', attrs: { file: '../a.ts', status: 'passed' } }); + expect(fileEntry).toMatchObject({ + status: 'ok', + // path.relative('/project', '/a.ts') -- OS-separated, so `..\a.ts` on Windows. + attrs: { file: path.join('..', 'a.ts'), status: 'passed' }, + }); expect(fileEntry?.attrs?.runId).toBeTypeOf('string'); expect(mockWriteTraceFile).toHaveBeenCalledWith(entries, expect.objectContaining({ runId: expect.any(String) })); }); diff --git a/packages/jest/src/resource-lock.ts b/packages/jest/src/resource-lock.ts index e94703ed..5798aee0 100644 --- a/packages/jest/src/resource-lock.ts +++ b/packages/jest/src/resource-lock.ts @@ -392,8 +392,21 @@ export const createResourceLockManager = ( return; } - await writeJsonFileAtomic(paths.ownerFilePath, nextMetadata); - scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId); + try { + await writeJsonFileAtomic(paths.ownerFilePath, nextMetadata); + scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId); + } catch (error) { + // A failed refresh is not fatal by design: the lock goes stale + // and another holder reclaims it. Swallow it so a transient + // write error (e.g. the owner file racing a concurrent release, + // or an EPERM on Windows when the directory is being torn down) + // never surfaces as an unhandled rejection from this interval. + scopedLogger.debug( + 'heartbeat refresh for ticket %s failed: %s', + ticketId, + error instanceof Error ? error.message : String(error), + ); + } } finally { heartbeatInFlight = false; } diff --git a/packages/platform-windows/.npmignore b/packages/platform-windows/.npmignore new file mode 100644 index 00000000..2ab54727 --- /dev/null +++ b/packages/platform-windows/.npmignore @@ -0,0 +1,4 @@ +**/__tests__/ +**/*.test.* +**/*.tsbuildinfo +dist/*.tsbuildinfo diff --git a/packages/platform-windows/README.md b/packages/platform-windows/README.md new file mode 100644 index 00000000..623771a0 --- /dev/null +++ b/packages/platform-windows/README.md @@ -0,0 +1,76 @@ +![harness-banner](https://react-native-harness.dev/harness-banner.jpg) + +[![mit licence][license-badge]][license] +[![npm downloads][npm-downloads-badge]][npm-downloads] +[![Chat][chat-badge]][chat] +[![PRs Welcome][prs-welcome-badge]][prs-welcome] + +React Native Windows platform for React Native Harness — runs your harness tests against a deployed React Native Windows app. + +## Installation + +```bash +npm install --save-dev @react-native-harness/platform-windows +# or +pnpm add -D @react-native-harness/platform-windows +# or +yarn add -D @react-native-harness/platform-windows +``` + +## Usage + +Add the Windows platform to your `rn-harness.config.mjs`: + +```javascript +import { windowsPlatform } from '@react-native-harness/platform-windows'; + +export default { + entryPoint: './index.js', + appRegistryComponentName: 'MyApp', + runners: [ + windowsPlatform({ + name: 'windows', + // Package.appxmanifest Identity/@Name + packageName: 'MyApp', + }), + ], +}; +``` + +Deploy the app before running the harness — the runner launches an already +installed package, it does not build: + +```bash +npx react-native run-windows --arch x64 --no-launch --no-packager +npx react-native-harness --harnessRunner windows +``` + +## API + +### `windowsPlatform(config)` + +**Parameters:** + +- `config.name` — unique name for the runner. +- `config.packageName` — the app's `Identity/@Name` from `Package.appxmanifest`. Used to look the deployed package up with `Get-AppxPackage`. +- `config.appId` — the app's `Application/@Id` from `Package.appxmanifest`. Combined with the package family name into the AUMID used to launch the app. Defaults to `App` (the React Native Windows template value). +- `config.processName` — the app's process name (without `.exe`), for tracking whether it is still running. Defaults to `packageName`. + +## Requirements + +- Windows 10/11 with the app already deployed (`react-native run-windows`). +- The harness Metro server reachable at the app's configured bundle URL (`http://localhost:8081` by default). + +## Made with ❤️ at Callstack + +`react-native-harness` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/react-native-harness?style=for-the-badge +[license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE +[npm-downloads-badge]: https://img.shields.io/npm/dm/react-native-harness?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/react-native-harness +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ./CONTRIBUTING.md +[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge +[chat]: https://discord.gg/xgGt7KAjxv diff --git a/packages/platform-windows/eslint.config.mjs b/packages/platform-windows/eslint.config.mjs new file mode 100644 index 00000000..8c8d168e --- /dev/null +++ b/packages/platform-windows/eslint.config.mjs @@ -0,0 +1,23 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredDependencies: ['vite', 'vitest'], + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/src/**/__tests__/**', + ], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/platform-windows/package.json b/packages/platform-windows/package.json new file mode 100644 index 00000000..ce13ab19 --- /dev/null +++ b/packages/platform-windows/package.json @@ -0,0 +1,35 @@ +{ + "name": "@react-native-harness/platform-windows", + "description": "React Native Windows platform for React Native Harness", + "version": "1.4.1", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "dependencies": { + "@react-native-harness/bundler-metro": "workspace:*", + "@react-native-harness/config": "workspace:*", + "@react-native-harness/platforms": "workspace:*", + "@react-native-harness/tools": "workspace:*", + "zod": "^3.25.67", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "metro-config": "*", + "metro-resolver": "*" + }, + "devDependencies": { + "metro-config": "*", + "metro-resolver": "*" + }, + "license": "MIT" +} diff --git a/packages/platform-windows/src/__tests__/metro-config-enhancer.test.ts b/packages/platform-windows/src/__tests__/metro-config-enhancer.test.ts new file mode 100644 index 00000000..3733d6c6 --- /dev/null +++ b/packages/platform-windows/src/__tests__/metro-config-enhancer.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CustomResolutionContext } from 'metro-resolver'; +import type { MetroConfig } from 'metro-config'; + +const resolve = vi.hoisted(() => vi.fn<(specifier: string) => string>()); + +vi.mock('node:module', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRequire: () => Object.assign(() => undefined, { resolve }), + }; +}); + +const { default: enhanceMetroConfig } = await import( + '../metro-config-enhancer.js' +); + +const context = { + resolveRequest: vi.fn(), +} as unknown as CustomResolutionContext; + +const baseConfig = (overrides?: Partial): MetroConfig => + ({ + resolver: { platforms: ['ios', 'android', 'native'] }, + serializer: {}, + ...overrides, + }) as MetroConfig; + +describe('windows metroConfigEnhancer', () => { + it('adds windows and native to resolver.platforms without duplicating', () => { + resolve.mockImplementation(() => { + throw new Error('not resolvable'); + }); + + const enhanced = enhanceMetroConfig(baseConfig(), { + projectRoot: '/tmp/app', + platformId: 'windows', + platformConfig: {}, + }) as MetroConfig; + + expect(enhanced.resolver?.platforms).toEqual([ + 'ios', + 'android', + 'native', + 'windows', + ]); + }); + + it('redirects react-native imports to react-native-windows for the windows platform', () => { + resolve.mockImplementation(() => { + throw new Error('not resolvable'); + }); + const next = vi.fn(); + + const enhanced = enhanceMetroConfig( + baseConfig({ resolver: { resolveRequest: next } }), + { projectRoot: '/tmp/app', platformId: 'windows', platformConfig: {} } + ) as MetroConfig; + + const resolveRequest = enhanced.resolver?.resolveRequest; + + resolveRequest?.(context, 'react-native', 'windows'); + expect(next).toHaveBeenLastCalledWith( + context, + 'react-native-windows', + 'windows' + ); + + resolveRequest?.( + context, + 'react-native/Libraries/Core/InitializeCore', + 'windows' + ); + expect(next).toHaveBeenLastCalledWith( + context, + 'react-native-windows/Libraries/Core/InitializeCore', + 'windows' + ); + }); + + it('leaves imports untouched for other platforms and unrelated modules', () => { + resolve.mockImplementation(() => { + throw new Error('not resolvable'); + }); + const next = vi.fn(); + + const resolveRequest = ( + enhanceMetroConfig( + baseConfig({ resolver: { resolveRequest: next } }), + { projectRoot: '/tmp/app', platformId: 'windows', platformConfig: {} } + ) as MetroConfig + ).resolver?.resolveRequest; + + resolveRequest?.(context, 'react-native', 'ios'); + expect(next).toHaveBeenLastCalledWith(context, 'react-native', 'ios'); + + resolveRequest?.(context, 'react-native-svg', 'windows'); + expect(next).toHaveBeenLastCalledWith(context, 'react-native-svg', 'windows'); + }); + + it('falls back to context.resolveRequest when the project sets no resolveRequest', () => { + resolve.mockImplementation(() => { + throw new Error('not resolvable'); + }); + + const resolveRequest = ( + enhanceMetroConfig(baseConfig(), { + projectRoot: '/tmp/app', + platformId: 'windows', + platformConfig: {}, + }) as MetroConfig + ).resolver?.resolveRequest; + + resolveRequest?.(context, 'react-native', 'windows'); + expect(context.resolveRequest).toHaveBeenLastCalledWith( + context, + 'react-native-windows', + 'windows' + ); + }); + + it('appends the react-native-windows InitializeCore to getModulesRunBeforeMainModule', () => { + resolve.mockImplementation( + (specifier) => `/tmp/app/node_modules/${specifier}.js` + ); + + const enhanced = enhanceMetroConfig( + baseConfig({ + serializer: { + getModulesRunBeforeMainModule: () => ['/rn/InitializeCore.js'], + }, + }), + { projectRoot: '/tmp/app', platformId: 'windows', platformConfig: {} } + ) as MetroConfig; + + expect( + enhanced.serializer?.getModulesRunBeforeMainModule?.('index.js') + ).toEqual([ + '/rn/InitializeCore.js', + '/tmp/app/node_modules/react-native-windows/Libraries/Core/InitializeCore.js', + ]); + }); + + it('leaves getModulesRunBeforeMainModule alone when RNW InitializeCore is not resolvable', () => { + resolve.mockImplementation(() => { + throw new Error('not resolvable'); + }); + + const runBeforeMain = () => ['/rn/InitializeCore.js']; + const enhanced = enhanceMetroConfig( + baseConfig({ + serializer: { getModulesRunBeforeMainModule: runBeforeMain }, + }), + { projectRoot: '/tmp/app', platformId: 'windows', platformConfig: {} } + ) as MetroConfig; + + expect(enhanced.serializer?.getModulesRunBeforeMainModule).toBe( + runBeforeMain + ); + }); +}); diff --git a/packages/platform-windows/src/__tests__/runner.test.ts b/packages/platform-windows/src/__tests__/runner.test.ts new file mode 100644 index 00000000..7ad6f998 --- /dev/null +++ b/packages/platform-windows/src/__tests__/runner.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import { AppNotInstalledError } from '@react-native-harness/platforms'; +import type { WindowsPlatformConfigInput } from '../config.js'; + +const pwsh = vi.hoisted(() => ({ + getPackageFamilyName: vi.fn<() => Promise>(), + isProcessRunning: vi.fn<() => Promise>(), + launchAppByAumid: vi.fn<() => Promise>(), + stopProcess: vi.fn<() => Promise>(), +})); + +vi.mock('../pwsh.js', () => pwsh); + +const harnessConfig = { metroPort: DEFAULT_METRO_PORT } as HarnessConfig; + +const config: WindowsPlatformConfigInput = { + name: 'windows', + packageName: 'ReactNativeNitroExample', +}; + +const init = () => ({ signal: new AbortController().signal }); + +afterEach(() => { + vi.clearAllMocks(); +}); + +const loadRunner = async () => (await import('../runner.js')).default; + +describe('getWindowsRunner', () => { + it('is invoked with the (config, harnessConfig, init) shape the harness session uses', async () => { + // Same regression guard as the other platform runners: the session calls + // module.default(config, runtimeConfig, init); an optional `init` param + // would drop out of Function.length and silently break `init.signal`. + const getWindowsRunner = await loadRunner(); + expect(getWindowsRunner.length).toBe(3); + }); + + it('throws AppNotInstalledError when the package is not deployed', async () => { + pwsh.getPackageFamilyName.mockResolvedValue(null); + const getWindowsRunner = await loadRunner(); + + await expect( + getWindowsRunner(config, harnessConfig, init()) + ).rejects.toBeInstanceOf(AppNotInstalledError); + }); + + it('launches the resolved AUMID and tracks the process', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(true); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner(config, harnessConfig, init()); + const session = await runner.createAppSession(); + + // A stale instance is cleared before launch, then the app is shell-activated. + expect(pwsh.stopProcess).toHaveBeenCalledWith('ReactNativeNitroExample'); + expect(pwsh.launchAppByAumid).toHaveBeenCalledWith( + 'Contoso.Example_1a2b3c!App' + ); + expect((await session.getState()).status).toBe('running'); + + await session.dispose(); + expect((await session.getState()).status).toBe('disposed'); + }); + + it('honours a custom appId and processName', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(true); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner( + { ...config, appId: 'MyApp', processName: 'Example' }, + harnessConfig, + init() + ); + await runner.createAppSession(); + + expect(pwsh.launchAppByAumid).toHaveBeenCalledWith( + 'Contoso.Example_1a2b3c!MyApp' + ); + expect(pwsh.stopProcess).toHaveBeenCalledWith('Example'); + }); + + it('throws if the process never starts after launch', async () => { + vi.useFakeTimers(); + try { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(false); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner(config, harnessConfig, init()); + + // Attach the rejection handler before advancing timers so the promise is + // never momentarily unhandled, then flush the fixed number of start-poll + // delays without waiting in real time. + const assertion = expect(runner.createAppSession()).rejects.toThrow( + /did not start/ + ); + await vi.advanceTimersByTimeAsync(15 * 400); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it('emits app_exited and reports the exited state when the process disappears', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + // Up for the startup check, gone by the first poll iteration (which runs + // before any delay), so this settles without waiting on the poll timer. + pwsh.isProcessRunning.mockResolvedValueOnce(true).mockResolvedValue(false); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner(config, harnessConfig, init()); + const session = await runner.createAppSession(); + + await vi.waitFor(async () => + expect((await session.getState()).status).toBe('exited') + ); + }); + + it('does not stop the app when the init signal aborts after session creation', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(true); + const getWindowsRunner = await loadRunner(); + + const controller = new AbortController(); + const runner = await getWindowsRunner(config, harnessConfig, { + signal: controller.signal, + }); + await runner.createAppSession(); + pwsh.stopProcess.mockClear(); + + controller.abort(); + + expect(pwsh.stopProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-windows/src/config.ts b/packages/platform-windows/src/config.ts new file mode 100644 index 00000000..4133f612 --- /dev/null +++ b/packages/platform-windows/src/config.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const WindowsAppLaunchOptionsSchema = z.object({}); + +export const WindowsPlatformConfigSchema = z.object({ + name: z.string().min(1, 'Name is required'), + /** + * The app's `Identity/@Name` from `Package.appxmanifest` — e.g. + * `ReactNativeNitroExample`. Used to look the deployed package up with + * `Get-AppxPackage`. + */ + packageName: z.string().min(1, 'packageName is required'), + /** + * The app's `Application/@Id` from `Package.appxmanifest`. Combined with the + * package family name into the AUMID used to launch the app. Defaults to + * `App`, which is what the React Native Windows template generates. + */ + appId: z.string().min(1).optional().default('App'), + /** + * The name of the app's process (without `.exe`), for tracking whether it is + * still running. Defaults to `packageName`, which is correct for the RNW + * template. + */ + processName: z.string().min(1).optional(), + appLaunchOptions: WindowsAppLaunchOptionsSchema.optional(), +}); + +export type WindowsAppLaunchOptions = z.infer< + typeof WindowsAppLaunchOptionsSchema +>; +export type WindowsPlatformConfig = z.infer; + +/** The `WindowsPlatformConfig` before Zod applies defaults (e.g. `appId`). */ +export type WindowsPlatformConfigInput = z.input< + typeof WindowsPlatformConfigSchema +>; diff --git a/packages/platform-windows/src/factory.ts b/packages/platform-windows/src/factory.ts new file mode 100644 index 00000000..9df4a074 --- /dev/null +++ b/packages/platform-windows/src/factory.ts @@ -0,0 +1,13 @@ +import { HarnessPlatform } from '@react-native-harness/platforms'; +import type { WindowsPlatformConfigInput } from './config.js'; + +export const windowsPlatform = ( + config: WindowsPlatformConfigInput +): HarnessPlatform => ({ + name: config.name, + config, + runner: import.meta.resolve('./runner.js'), + metroConfigEnhancer: import.meta.resolve('./metro-config-enhancer.js'), + platformId: 'windows', + getResourceLockKey: () => `windows:${config.packageName}`, +}); diff --git a/packages/platform-windows/src/index.ts b/packages/platform-windows/src/index.ts new file mode 100644 index 00000000..42460fd7 --- /dev/null +++ b/packages/platform-windows/src/index.ts @@ -0,0 +1,6 @@ +export { windowsPlatform } from './factory.js'; +export type { + WindowsPlatformConfig, + WindowsPlatformConfigInput, + WindowsAppLaunchOptions, +} from './config.js'; diff --git a/packages/platform-windows/src/metro-config-enhancer.ts b/packages/platform-windows/src/metro-config-enhancer.ts new file mode 100644 index 00000000..81eb6b76 --- /dev/null +++ b/packages/platform-windows/src/metro-config-enhancer.ts @@ -0,0 +1,112 @@ +import { createRequire } from 'node:module'; +import { logger } from '@react-native-harness/tools'; +import type { + MetroConfigEnhancer, + MetroConfigEnhancerContext, +} from '@react-native-harness/bundler-metro'; +import type { MetroConfig } from 'metro-config'; +import type { CustomResolutionContext, Resolution } from 'metro-resolver'; + +const require = createRequire(import.meta.url); +const log = logger.child('platform-windows'); + +const RNW_PACKAGE = 'react-native-windows'; + +type MetroResolveRequest = ( + context: CustomResolutionContext, + moduleName: string, + platform: string | null +) => Resolution; + +/** + * Redirects `react-native` / `react-native/*` imports to `react-native-windows` + * when Metro is bundling for the `windows` platform, delegating to whatever + * `resolveRequest` the harness has already composed. + * + * `react-native start` installs this via `@react-native/community-cli-plugin`'s + * `reactNativePlatformResolver`, keyed off the platform's `npmPackageName` in + * the React Native CLI config. The harness loads Metro's config directly and + * never runs that plugin, so the platform package supplies the redirect itself. + */ +const withWindowsPackageRedirect = ( + next: MetroResolveRequest | undefined +): MetroResolveRequest => { + const passThrough: MetroResolveRequest = + next ?? ((ctx, name, plat) => ctx.resolveRequest(ctx, name, plat)); + + return (context, moduleName, platform) => { + if (platform !== 'windows') { + return passThrough(context, moduleName, platform); + } + + let redirected = moduleName; + if (moduleName === 'react-native') { + redirected = RNW_PACKAGE; + } else if (moduleName.startsWith('react-native/')) { + redirected = `${RNW_PACKAGE}/${moduleName.slice('react-native/'.length)}`; + } + + return passThrough(context, redirected, platform); + }; +}; + +/** + * Resolves `react-native-windows/Libraries/Core/InitializeCore` from the + * project. Without it running before the entry point a `windows` bundle never + * calls `setUpBatchedBridge`, so `HMRClient` is not a registered callable + * module and React Native Windows redboxes the instance before the harness can + * attach. Metro only emits a `require()` for a run-before module that ends up + * in the graph, so this is inert for any non-Windows bundle. + */ +const resolveInitializeCore = (projectRoot: string): string | null => { + const specifier = `${RNW_PACKAGE}/Libraries/Core/InitializeCore`; + try { + return require.resolve(specifier, { paths: [projectRoot] }); + } catch { + log.warn( + 'could not resolve %s from %s; Windows bundles may fail to initialize', + specifier, + projectRoot + ); + return null; + } +}; + +const enhanceMetroConfig: MetroConfigEnhancer = ( + metroConfig: MetroConfig, + { projectRoot }: MetroConfigEnhancerContext +): MetroConfig => { + const initializeCore = resolveInitializeCore(projectRoot); + const existingRunBeforeMainModule = + metroConfig.serializer?.getModulesRunBeforeMainModule; + + return { + ...metroConfig, + resolver: { + ...metroConfig.resolver, + platforms: [ + ...new Set([ + ...(metroConfig.resolver?.platforms ?? ['ios', 'android']), + 'windows', + 'native', + ]), + ], + resolveRequest: withWindowsPackageRedirect( + metroConfig.resolver?.resolveRequest ?? undefined + ), + }, + serializer: { + ...metroConfig.serializer, + ...(initializeCore + ? { + getModulesRunBeforeMainModule: (entryPoint: string) => [ + ...(existingRunBeforeMainModule?.(entryPoint) ?? []), + initializeCore, + ], + } + : {}), + }, + }; +}; + +export default enhanceMetroConfig; diff --git a/packages/platform-windows/src/pwsh.ts b/packages/platform-windows/src/pwsh.ts new file mode 100644 index 00000000..ff0e0d3b --- /dev/null +++ b/packages/platform-windows/src/pwsh.ts @@ -0,0 +1,73 @@ +import { spawn } from '@react-native-harness/tools'; + +/** + * Runs a PowerShell snippet non-interactively and returns its trimmed stdout. + * `-NoProfile` keeps it fast and hermetic; `-NonInteractive` makes sure it + * never blocks on a prompt. + */ +export const runPowerShell = async (script: string): Promise => { + const { stdout } = await spawn( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', script], + { windowsHide: true } + ); + return stdout.trim(); +}; + +/** + * Resolves the PackageFamilyName of a deployed MSIX package from its identity + * name (`Package.appxmanifest` `Identity/@Name`). Returns `null` when the app + * is not deployed. + */ +export const getPackageFamilyName = async ( + identityName: string +): Promise => { + const pfn = await runPowerShell( + `(Get-AppxPackage -Name '${identityName}' | Select-Object -First 1).PackageFamilyName` + ); + return pfn === '' ? null : pfn; +}; + +/** Whether at least one process with the given name (no `.exe`) is running. */ +export const isProcessRunning = async ( + processName: string +): Promise => { + try { + const count = await runPowerShell( + `(Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Measure-Object).Count` + ); + return Number(count) > 0; + } catch { + return false; + } +}; + +/** + * Launches a deployed MSIX app by its AUMID + * (`!`). + * + * `explorer.exe shell:AppsFolder\` is the reliable activation path, but + * `explorer.exe` almost always exits non-zero even on success, so its failure + * is swallowed — the caller confirms the app came up by polling for its + * process. + */ +export const launchAppByAumid = async (aumid: string): Promise => { + try { + await spawn('explorer.exe', [`shell:AppsFolder\\${aumid}`], { + windowsHide: true, + }); + } catch { + // Expected: explorer.exe reports a non-zero exit even on success. + } +}; + +/** Force-terminates every process with the given name. Safe to call when none exist. */ +export const stopProcess = async (processName: string): Promise => { + try { + await runPowerShell( + `Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Stop-Process -Force` + ); + } catch { + // Nothing to stop, or it exited between the query and the kill. + } +}; diff --git a/packages/platform-windows/src/runner.ts b/packages/platform-windows/src/runner.ts new file mode 100644 index 00000000..fac31ec4 --- /dev/null +++ b/packages/platform-windows/src/runner.ts @@ -0,0 +1,173 @@ +import { + createAppSessionEmitter, + type AppSession, + type AppSessionState, + AppNotInstalledError, + type HarnessPlatformRunnerFactory, +} from '@react-native-harness/platforms'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { logger } from '@react-native-harness/tools'; +import { + WindowsPlatformConfigSchema, + type WindowsPlatformConfigInput, +} from './config.js'; +import { + getPackageFamilyName, + isProcessRunning, + launchAppByAumid, + stopProcess, +} from './pwsh.js'; + +const log = logger.child('platform-windows'); + +const APP_EXIT_POLL_INTERVAL_MS = 1000; +const APP_START_POLL_INTERVAL_MS = 400; +const APP_START_POLL_ATTEMPTS = 15; + +const delay = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); + +const getWindowsRunner: HarnessPlatformRunnerFactory< + WindowsPlatformConfigInput, + HarnessConfig +> = async (config, _harnessConfig, init) => { + void _harnessConfig; + + const parsedConfig = WindowsPlatformConfigSchema.parse(config); + const { packageName, appId } = parsedConfig; + const processName = parsedConfig.processName ?? packageName; + + const packageFamilyName = await getPackageFamilyName(packageName); + + if (packageFamilyName == null) { + throw new AppNotInstalledError(packageName, 'this machine'); + } + + const aumid = `${packageFamilyName}!${appId}`; + log.debug('resolved AUMID %s for package %s', aumid, packageName); + + return { + createAppSession: async (): Promise => { + // Clean slate: never attach to an instance left over from a previous run. + await stopProcess(processName); + await launchAppByAumid(aumid); + + // `explorer.exe` returns before the app is up (and lies about its exit + // code), so confirm the process actually started. `init.signal` cancels + // this finite readiness wait; it is not a disposal signal. + let started = false; + for (let attempt = 0; attempt < APP_START_POLL_ATTEMPTS; attempt += 1) { + if (await isProcessRunning(processName)) { + started = true; + break; + } + await delay(APP_START_POLL_INTERVAL_MS, init.signal); + } + + if (!started) { + await stopProcess(processName); + throw new Error( + `The Windows app '${processName}' did not start after launching ${aumid}. ` + + `Deploy it first, e.g. \`npx react-native run-windows --arch x64 --no-launch\`.` + ); + } + + const emitter = createAppSessionEmitter(); + let state: AppSessionState = { status: 'running' }; + let disposed = false; + let stopPolling = false; + let pollDelayTimeout: ReturnType | null = null; + let resolvePollDelay: (() => void) | null = null; + + // Unlike a raced delay() loser (which is fine to just discard), this + // wait is directly `await`ed by pollTask with nothing else racing it, + // so cancelling it must also resolve the promise immediately — + // otherwise dispose() blocks on `await pollTask` for up to + // APP_EXIT_POLL_INTERVAL_MS instead of returning right away. + const waitForNextPoll = () => + new Promise((resolve) => { + resolvePollDelay = () => { + resolvePollDelay = null; + pollDelayTimeout = null; + resolve(); + }; + + pollDelayTimeout = setTimeout(() => { + resolvePollDelay?.(); + }, APP_EXIT_POLL_INTERVAL_MS); + }); + + const cancelPendingPollDelay = () => { + if (pollDelayTimeout) { + clearTimeout(pollDelayTimeout); + pollDelayTimeout = null; + } + + resolvePollDelay?.(); + }; + + const pollTask = (async () => { + while (!stopPolling) { + if (!(await isProcessRunning(processName))) { + if (!disposed && state.status === 'running') { + state = { + status: 'exited', + occurredAt: Date.now(), + reason: 'process-gone', + }; + emitter.emit({ type: 'app_exited' }); + } + return; + } + + if (stopPolling) { + return; + } + + await waitForNextPoll(); + } + })(); + + const session: AppSession = { + dispose: async () => { + if (disposed) { + return; + } + + disposed = true; + stopPolling = true; + cancelPendingPollDelay(); + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + await stopProcess(processName); + await pollTask; + }, + getState: async () => state, + getLogs: () => [], + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; + + return session; + }, + dispose: async () => { + await stopProcess(processName); + }, + }; +}; + +export default getWindowsRunner; diff --git a/packages/platform-windows/tsconfig.json b/packages/platform-windows/tsconfig.json new file mode 100644 index 00000000..5baa264c --- /dev/null +++ b/packages/platform-windows/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../tools" + }, + { + "path": "../platforms" + }, + { + "path": "../config" + }, + { + "path": "../bundler-metro" + }, + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/platform-windows/tsconfig.lib.json b/packages/platform-windows/tsconfig.lib.json new file mode 100644 index 00000000..98f2a80b --- /dev/null +++ b/packages/platform-windows/tsconfig.lib.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../tools/tsconfig.lib.json" + }, + { + "path": "../platforms/tsconfig.lib.json" + }, + { + "path": "../config/tsconfig.lib.json" + }, + { + "path": "../bundler-metro/tsconfig.lib.json" + } + ] +} diff --git a/packages/platform-windows/vite.config.ts b/packages/platform-windows/vite.config.ts new file mode 100644 index 00000000..ba257310 --- /dev/null +++ b/packages/platform-windows/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/platform-windows', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index c5561b7e..f15048bc 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -24,6 +24,7 @@ export type { RunTarget, VegaAppLaunchOptions, WebAppLaunchOptions, + WindowsAppLaunchOptions, } from './types.js'; export { createAppSessionEmitter, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 7f4dd932..d1908d9c 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -105,11 +105,14 @@ export type WebAppLaunchOptions = Record; export type VegaAppLaunchOptions = Record; +export type WindowsAppLaunchOptions = Record; + export type AppLaunchOptions = | AndroidAppLaunchOptions | AppleAppLaunchOptions | WebAppLaunchOptions - | VegaAppLaunchOptions; + | VegaAppLaunchOptions + | WindowsAppLaunchOptions; export type CollectNativeCoverageOptions = { pods: string[]; diff --git a/packages/runtime/src/client/getDeviceDescriptor.test.ts b/packages/runtime/src/client/getDeviceDescriptor.test.ts new file mode 100644 index 00000000..48bb796a --- /dev/null +++ b/packages/runtime/src/client/getDeviceDescriptor.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getDeviceDescriptor } from './getDeviceDescriptor.js'; + +const mocks = vi.hoisted(() => ({ + Platform: { + OS: 'ios' as string, + constants: {} as Record, + }, +})); + +vi.mock('react-native', () => ({ + Platform: mocks.Platform, +})); + +beforeEach(() => { + mocks.Platform.OS = 'ios'; + mocks.Platform.constants = {}; +}); + +describe('getDeviceDescriptor', () => { + it('describes an iOS device', () => { + mocks.Platform.OS = 'ios'; + mocks.Platform.constants = { osVersion: '17.4' }; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'ios', + manufacturer: 'Apple', + model: 'Unknown', + osVersion: '17.4', + }); + }); + + it('describes an Android device', () => { + mocks.Platform.OS = 'android'; + mocks.Platform.constants = { + Manufacturer: 'Google', + Model: 'Pixel 8', + Release: '14', + }; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'android', + manufacturer: 'Google', + model: 'Pixel 8', + osVersion: '14', + }); + }); + + it('describes web', () => { + mocks.Platform.OS = 'web'; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'web', + manufacturer: '', + model: '', + osVersion: '', + }); + }); + + it('maps the kepler OS to the vega platform', () => { + mocks.Platform.OS = 'kepler'; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'vega', + manufacturer: '', + model: '', + osVersion: '', + }); + }); + + it('describes a Windows device', () => { + mocks.Platform.OS = 'windows'; + mocks.Platform.constants = { osVersion: 10 }; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'windows', + manufacturer: '', + model: '', + osVersion: '10', + }); + }); + + it('tolerates a Windows device without an osVersion constant', () => { + mocks.Platform.OS = 'windows'; + mocks.Platform.constants = {}; + + expect(getDeviceDescriptor().osVersion).toBe(''); + }); + + it('throws for an unknown platform', () => { + mocks.Platform.OS = 'tizen'; + + expect(() => getDeviceDescriptor()).toThrow('Unsupported platform'); + }); +}); diff --git a/packages/runtime/src/client/getDeviceDescriptor.ts b/packages/runtime/src/client/getDeviceDescriptor.ts index 2819727b..c159e4a4 100644 --- a/packages/runtime/src/client/getDeviceDescriptor.ts +++ b/packages/runtime/src/client/getDeviceDescriptor.ts @@ -11,7 +11,7 @@ const getPlatform = (): Platform | PlatformKeplerStatic => { }; export type DeviceDescriptor = { - platform: 'ios' | 'android' | 'vega' | 'web'; + platform: 'ios' | 'android' | 'vega' | 'web' | 'windows'; manufacturer: string; model: string; osVersion: string; @@ -56,5 +56,14 @@ export const getDeviceDescriptor = (): DeviceDescriptor => { }; } + if (platform.OS === 'windows') { + return { + platform: 'windows', + manufacturer: '', + model: '', + osVersion: String(platform.constants?.osVersion ?? ''), + }; + } + throw new Error('Unsupported platform'); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9912625e..3e41bc20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,34 @@ importers: specifier: ^3.25.67 version: 3.25.67 + packages/platform-windows: + dependencies: + '@react-native-harness/bundler-metro': + specifier: workspace:* + version: link:../bundler-metro + '@react-native-harness/config': + specifier: workspace:* + version: link:../config + '@react-native-harness/platforms': + specifier: workspace:* + version: link:../platforms + '@react-native-harness/tools': + specifier: workspace:* + version: link:../tools + tslib: + specifier: ^2.3.0 + version: 2.8.1 + zod: + specifier: ^3.25.67 + version: 3.25.67 + devDependencies: + metro-config: + specifier: '*' + version: 0.83.3 + metro-resolver: + specifier: '*' + version: 0.83.3 + packages/platforms: dependencies: '@react-native-harness/tools': @@ -4114,6 +4142,7 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -4654,6 +4683,7 @@ packages: eslint@9.29.0: resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' diff --git a/tsconfig.json b/tsconfig.json index 02cdd5eb..0d520766 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,6 +63,9 @@ { "path": "./packages/coverage-ios" }, + { + "path": "./packages/platform-windows" + }, { "path": "./website" } diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 72449980..2a0d6f16 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -140,6 +140,7 @@ For detailed installation and configuration instructions, please refer to the pl - [**Android**](/docs/platforms/android) - [**iOS**](/docs/platforms/ios) - [**Web**](/docs/platforms/web) +- [**Windows**](/docs/platforms/windows) ## Default Runner diff --git a/website/src/docs/guides/ci-cd.md b/website/src/docs/guides/ci-cd.md index f634f4ff..d89c6895 100644 --- a/website/src/docs/guides/ci-cd.md +++ b/website/src/docs/guides/ci-cd.md @@ -36,7 +36,7 @@ The action reads your `rn-harness.config.mjs` file to determine the selected run The action accepts the following inputs: -- `app` (optional): Path to your built app (`.apk` for Android, `.app` for iOS). Not needed for web runners +- `app` (optional): Path to your built app (`.apk` for Android, `.app` for iOS). Not needed for web or Windows runners - `runner` (required): The runner name from your Harness config (for example `"android"`, `"ios"`, or `"chromium"`) - `projectRoot` (optional): The project root directory (defaults to the repository root) - `uploadVisualTestArtifacts` (optional): Whether to upload visual test diff and actual images as artifacts @@ -268,6 +268,50 @@ The official action supports web runners as well. At the moment, the action inst If your workflow depends on a different browser setup, make that expectation explicit in your CI configuration. +## Windows in CI + +The official action supports the `windows` runner. Run the job on a `windows-*` runner, deploy the app with `react-native run-windows` in an earlier step, then call the action with no `app` input — the Windows runner launches an already-deployed package by its identity (see the [Windows platform guide](/docs/platforms/windows)). + +```yaml +jobs: + test-windows: + name: Test Windows + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Build and deploy the Windows app + run: npx react-native run-windows --arch x64 --no-launch --no-packager --logging + + # Keep @v… in sync with the react-native-harness version in package.json + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.0.0 + with: + runner: windows + packageManager: pnpm +``` + +The Windows toolchain (Visual Studio with the C++ workload, the Windows SDK, NuGet) is preinstalled on GitHub's `windows-*` images. As with Android and iOS, you can cache the build output between runs so unchanged native modules don't trigger a rebuild. + ## Build Artifact Caching The workflow includes build artifact caching to significantly reduce CI execution times. When native modules haven't changed, you can reuse the same debug builds instead of rebuilding from scratch. diff --git a/website/src/docs/platforms/_meta.json b/website/src/docs/platforms/_meta.json index facb818e..ead4d63d 100644 --- a/website/src/docs/platforms/_meta.json +++ b/website/src/docs/platforms/_meta.json @@ -13,5 +13,10 @@ "type": "file", "name": "web", "label": "Web" + }, + { + "type": "file", + "name": "windows", + "label": "Windows" } ] diff --git a/website/src/docs/platforms/windows.mdx b/website/src/docs/platforms/windows.mdx new file mode 100644 index 00000000..4b694afd --- /dev/null +++ b/website/src/docs/platforms/windows.mdx @@ -0,0 +1,87 @@ +import { PackageManagerTabs } from '@theme'; + +# Windows + +React Native Harness runs tests against a deployed [React Native Windows](https://microsoft.github.io/react-native-windows/) app. + +## Overview + +Unlike Android and iOS, where Harness boots an emulator or simulator, the Windows runner works with an app you have **already deployed** to the machine. Harness resolves the app by its package identity, launches it, and tracks its process for the duration of the run. Build and deploy the app with `react-native run-windows` before invoking Harness. + +## Installation + + + +## Configuration + +Import the Windows platform helper in your `rn-harness.config.mjs`: + +```javascript +import { windowsPlatform } from '@react-native-harness/platform-windows'; + +export default { + entryPoint: './index.js', + appRegistryComponentName: 'MyApp', + runners: [ + windowsPlatform({ + name: 'windows', + // The Identity/@Name from windows//Package.appxmanifest + packageName: 'MyApp', + }), + ], +}; +``` + +### Options + +| Option | Type | Default | Description | +| :------------ | :------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | `string` | – | Unique name for the runner. | +| `packageName` | `string` | – | The `Identity/@Name` from `Package.appxmanifest`. Harness looks the deployed package up with `Get-AppxPackage -Name `. | +| `appId` | `string` | `'App'` | The `Application/@Id` from `Package.appxmanifest`. Combined with the package family name into the AUMID used to activate the app. | +| `processName` | `string` | `packageName` | The app's process name (without `.exe`), used to detect whether it is still running. | + +### Finding the package identity + +Open `windows//Package.appxmanifest` and look at the `Identity` and `Application` elements: + +```xml + +... + + + +``` + +Here `packageName` is `MyApp` and `appId` is `App` (the default). + +## Deploying the app + +The Windows runner never builds — deploy the app first: + +```bash +npx react-native run-windows --arch x64 --no-launch --no-packager --logging +``` + +`--no-launch` registers the MSIX package without starting it (Harness starts it), and `--no-packager` keeps `run-windows` from starting its own Metro server on the port Harness wants. + +Then run the tests: + +```bash +npx react-native-harness --harnessRunner windows +``` + +## Metro configuration + +Harness applies the same out-of-tree platform wiring that `react-native start` does (the `react-native` → `react-native-windows` resolver redirect and the Windows `InitializeCore`), so a plain `metro.config.js` works — you do **not** need to add a `windows` case to `resolver.resolveRequest` or `serializer.getModulesRunBeforeMainModule` yourself. + +## Requirements + +- Windows 10 or 11. +- The React Native Windows toolchain (Visual Studio with the C++ workload, the Windows SDK) to build the app. +- The app deployed via `react-native run-windows` before the run. +- The harness Metro server reachable at the app's bundle URL (`http://localhost:8081` by default). An RNW **Debug** build points there out of the box. + +## CI + +The official GitHub Action supports the `windows` runner. Run the job on a `windows-*` runner, deploy the app in an earlier step, then invoke the action without an `app` input — see [Running in CI/CD](/docs/guides/ci-cd#windows-in-ci).