From 048a0d0356b8d085837c34a5ffa26b4576736f1c Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 14:46:11 -0700 Subject: [PATCH] feat: support running the harness on a Windows host Two host-side assumptions broke React Native Windows before the harness could do anything useful: - The config reader passed a bare absolute path to dynamic import() for `.mjs` configs. Node only accepts a file:// URL there; on Windows the drive letter is read as a URL scheme and rejected (ERR_UNSUPPORTED_ESM_URL_SCHEME). Normalize with pathToFileURL. - getDeviceDescriptor threw "Unsupported platform" for Platform.OS === 'windows', aborting the bridge handshake. Add a `windows` case and widen the DeviceDescriptor platform union (in both the runtime and bridge copies of the type). Co-Authored-By: Claude Sonnet 5 --- .../version-plan-1787953392799.md | 5 + packages/bridge/src/shared.ts | 2 +- packages/config/src/__tests__/reader.test.ts | 82 ++++++++++++++++ packages/config/src/reader.ts | 7 +- .../src/client/getDeviceDescriptor.test.ts | 95 +++++++++++++++++++ .../runtime/src/client/getDeviceDescriptor.ts | 11 ++- 6 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 .nx/version-plans/version-plan-1787953392799.md create mode 100644 packages/config/src/__tests__/reader.test.ts create mode 100644 packages/runtime/src/client/getDeviceDescriptor.test.ts 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/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/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/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/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'); };