feat: react-native-windows support - #187
Conversation
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 <noreply@anthropic.com>
* feat(bundler-metro): wire out-of-tree platforms into Metro The harness loads Metro's config with a bare `Metro.loadConfig`, bypassing `@react-native/community-cli-plugin`. That plugin is what teaches Metro about out-of-tree platforms (react-native-windows, react-native-macos): the `react-native` -> platform-package resolver redirect, the platform's `Libraries/Core/InitializeCore`, and the extra `resolver.platforms` entries. Without it a `--platform windows` bundle can't resolve `react-native/...` and the instance redboxes before HMRClient is registered, so every RNW + harness project has had to reproduce this in its own `metro.config.js`. Read the React Native CLI config and, when an out-of-tree platform is registered there, apply the same wiring `loadMetroConfig` does. Gated on that detection, so iOS/Android runs produce a byte-identical Metro config. `@react-native-community/cli-config` is resolved from the project (optional peer dep); its absence just means no out-of-tree platforms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(bundler-metro): rename out-of-tree-platforms to metro-platforms Matches the package's metro-* naming (metro-block-list, metro-cache, metro-workers). No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
New `@react-native-harness/platform-windows` package, mirroring
`platform-vega`: `windowsPlatform({ name, packageName })` in
`rn-harness.config.mjs` runs the harness against an already-deployed RNW
app.
The runner resolves the package family name from the manifest identity
name via `Get-AppxPackage`, shell-activates the app by its AUMID
(`<pfn>!<appId>`, `appId` defaulting to the template's `App`), confirms
the process came up, then polls it and emits `app_exited` when it goes
away. `init.signal` cancels the readiness wait but never disposes — the
harness owns that.
Also adds `WindowsAppLaunchOptions` to `@react-native-harness/platforms`.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…#4) `${{ github.action_path }}` is a native path, so on a Windows runner it is `D:\a\_actions\...`. Passed unquoted to `node` inside a `shell: bash` step, bash eats the backslashes (`D:\a\_actions` -> `D:a_actions`) and the helper scripts fail to load — the action is unusable on Windows. Quote every `${{ github.action_path }}` interpolation. Also: - exempt the `windows` platform from the "app input required" check, like web — the harness launches an already-deployed package by identity; - derive `HARNESS_PROJECT_ROOT` with `pwd -W` so hook subprocesses get a native `D:/...` path rather than an unusable `/d/...` msys path; - regenerate the bundled `actions/shared/*.cjs`, which picks up the earlier `pathToFileURL` config-reader fix (#1) that the Windows load-config step needs. The deprecated per-platform sub-actions get the same quoting fix. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Add a Windows platform guide covering `windowsPlatform()` config, the `react-native run-windows --no-launch` deploy step the runner expects, and where to find the package identity name. Add a "Windows in CI" section to the CI/CD guide with a `windows-latest` workflow example, and list Windows alongside the other platforms in the configuration guide. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The suite assumed POSIX path separators in several places, so it failed when run from Windows (CI is Linux, so this never showed up there): - bundler-metro `paths.test.ts`: passed `/tmp/...`, which is not absolute on Windows, so `path.resolve` prepended the cwd drive; - bundler-metro `metro-block-list.test.ts`: asserted against forward-slash paths, but Metro's `exclusionList` rewrites its patterns to `path.sep`, so an inherited blockList only matches the host separator; - jest `execute-run.test.ts`: expected `../a.ts` for a span attribute that is `path.relative`-derived (`..\a.ts` on Windows); - cache `boundary.test.ts`: matched a `path.relative` result against a forward-slash allowlist entry. Also fixes two real issues surfaced along the way: - `resource-lock.ts`: a heartbeat refresh whose write throws (owner file racing a release, or a transient FS error such as EPERM on Windows when a directory is torn down) escaped the `setInterval` callback as an unhandled rejection. Swallow it — a missed refresh just lets the lock go stale and be reclaimed, which is the designed behavior. - `platform-windows` `runner.test.ts`: attach the rejection handler before advancing fake timers so the promise is never momentarily unhandled. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
`RunnerSchema` is a bare `z.object()`, which strips unknown keys, so the `getResourceLockKey` every platform factory sets never survived `ConfigSchema.parse`. The session's `platform.getResourceLockKey?.()` was therefore always undefined and every run fell back to `<platformId>:<runnerName>` — serializing all runs of a platform even when they target different devices. Add the field to the schema (typed as `() => string | Promise<string>`) so the platform-provided key is honored. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
@StasDoskalenko is attempting to deploy a commit to the Callstack Team on Vercel. A member of the Team first needs to authorize it. |
| HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} | ||
| run: | | ||
| node ${{ github.action_path }}/../shared/index.cjs | ||
| node "${{ github.action_path }}/../shared/index.cjs" |
There was a problem hiding this comment.
unfortunately windows runners require ""
|
Hey @StasDoskalenko! We could expose an enhanceMetroConfig method that would be called when a platform declares one, passing it the Metro config produced by Harness and allowing the platform to make additional changes. If any new platform comes with different Metro requirements, it can simply make the necessary changes itself. There would be no need to reach out to bundler-metro again. WDYT? |
Resolves conflicts from the GitHub Action refactor (callstackincubator#185, deleted the bundled actions/*.cjs and packages/github-action, moved the steps to a 'harness ci' CLI subcommand). The Windows-specific action.yml changes (windows exempt from the app-input check, pwd -W for HARNESS_PROJECT_ROOT) carry over; the github.action_path quoting fix is obsolete now that the action no longer runs bundled scripts.
`harness ci load-config` writes `projectRoot=` to GITHUB_OUTPUT from a raw `path.relative`, which is `apps\foo` on a Windows runner. The action feeds that into `actions/cache` globs, `hashFiles()`, and a bash `working-directory`, all of which want `/`. Normalize the separator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@V3RON hey 👋 Thanks for taking a look! I actually think that would be a better shape than what I've got. Pushing the Metro tweaks into the platform package means bundler-metro doesn't need to know anything about platforms, and it lets us drop the Couple of things to consider: The enhancer probably can't be a plain function hanging off the platform object. The other bit is that the enhancer needs to know which runner you're on. Good news is the session already has the resolved runner right before it spins up Metro, so it's just a matter of passing it down into The resolver redirect and the Happy to rework it along these lines. Want it in this PR, or should we land the rest first and do it as a follow-up? |
|
Sounds good! Let's make it like this: |
## Description
Adds a per-platform `metroConfigEnhancer` hook so a platform package can
adjust the Metro config the harness composes, without the bundler
needing to know the platform exists.
The harness loads Metro's config with a bare `Metro.loadConfig`,
bypassing `@react-native/community-cli-plugin`. That plugin is what
teaches Metro about out-of-tree platforms (React Native Windows, macOS):
the `react-native` → platform-package resolver redirect, the platform's
`Libraries/Core/InitializeCore`, and the extra `resolver.platforms`
entries. A project targeting one of those currently has to reproduce
that wiring by hand in its `metro.config.js`.
This gives the platform a seam to do it itself:
```ts
// HarnessPlatform
metroConfigEnhancer?: string; // a module specifier, like `runner` / `cli`
// the module's default export
type MetroConfigEnhancer<TMetroConfig> = (
metroConfig: TMetroConfig,
context: { projectRoot: string }
) => TMetroConfig | Promise<TMetroConfig>;
```
A platform factory sets it with
`import.meta.resolve('./metro-config-enhancer.js')`, the same way it
already sets `runner`. Nothing changes for a platform that doesn't set
one.
## Related Issue
Comes out of the review discussion on #187 (React Native Windows
support), where the suggestion was to move the Metro-config
augmentations into the platform packages instead of teaching
`bundler-metro` about specific platforms — "expose an
`enhanceMetroConfig` method that would be called when a platform
declares one". This PR is that mechanism on its own; the RNW platform
package that uses it lands separately.
Happy to open a tracking issue if you'd prefer one on file.
## Context
- The enhancer runs **last**, once `withRnHarness` has finished
composing the config, so it sees the harness resolver, cache config,
serializer, everything, and returns a further-adjusted config.
- It runs only for the **selected runner** — `harness-session` already
has the resolved runner right before it initializes Metro, so it just
passes `platform.metroConfigEnhancer` down through `MetroOptions`.
- It's a module **path**, not a function on the config object, for two
reasons: it matches the existing `runner` / `cli` fields, and
`RunnerSchema` is a bare `z.object()` that strips unknown keys, so a
function would be dropped during config parsing.
- `@react-native-harness/bundler-metro` gains a workspace dependency on
`@react-native-harness/platforms` for the `MetroConfigEnhancer` type
(type-only import).
Additive and opt-in: no platform in this repo sets
`metroConfigEnhancer`, so existing runs produce an identical Metro
config.
## Testing
- `packages/config`: new `runner-schema.test.ts` — the enhancer path
survives `ConfigSchema.parse`, is optional, and a non-string is
rejected.
- `packages/bundler-metro`: `withRnHarness.test.ts` — with no enhancer
the composed config is returned untouched; with one, it runs against the
composed config and receives `{ projectRoot }`; an async enhancer is
awaited; a module with no default export function throws.
- `nx run-many -t typecheck build lint` pass for the affected projects.
---------
Co-authored-by: Szymon Chmal <szymon@chmal.it>
Brings in the per-platform metroConfigEnhancer hook (callstackincubator#190). The fork's out-of-tree platform wiring in withRnHarness / metro-platforms stays as is; the enhancer mechanism lands alongside it, additive and with no consumer yet. Conflicts: - bundler-metro/src/withRnHarness.ts: keep both the metro-platforms imports and the new MetroConfigEnhancer type import. - bundler-metro/src/__tests__/withRnHarness.test.ts: keep the out-of-tree platform tests and the new metroConfigEnhancer describe block. - config/src/__tests__/runner-schema.test.ts: keep both the getResourceLockKey and metroConfigEnhancer test blocks.
Move the out-of-tree platform Metro wiring out of bundler-metro and into the platform-windows package, behind the metroConfigEnhancer hook (callstackincubator#190). bundler-metro no longer reads @react-native-community/cli-config to detect out-of-tree platforms, and withRnHarness drops the react-native -> platform-package redirect, the extra resolver.platforms entries, and the platform InitializeCore append. That logic now lives in platform-windows/src/metro-config-enhancer.ts, which windowsPlatform() points its metroConfigEnhancer at. withRnHarness returns to its upstream shape. - delete bundler-metro/src/metro-platforms.ts + its test - drop the @react-native-community/cli-config optional peer from bundler-metro - platform-windows: metro-config-enhancer.ts (redirect + resolver.platforms + RNW InitializeCore) + tests; factory sets metroConfigEnhancer - export MetroConfigEnhancer / MetroConfigEnhancerContext from bundler-metro Validated against the nitro react-native-windows e2e (RN 0.85 / RNW 0.85): the windows bundle resolves and the app connects with the redirect coming only from the enhancer and metro.config.js carrying no windows-specific wiring. The earlier "base react-native devtools subtree leaks into the windows graph" symptom did not reproduce; a cold-cache run and the belt-and-suspenders case (project metro.config.js redirect still present) both pass.
Description
Adds React Native Windows as a supported platform. A project can run its harness
suite against a deployed RNW app by adding
@react-native-harness/platform-windowsand a
windowsPlatform()runner torn-harness.config.mjs. No custom runnerscript, no
metro.config.jschanges, no patches.Four things were missing:
Host support. Loading an ESM
rn-harness.config.mjsfailed when theharness process runs on Windows: the config reader passed a bare absolute
path to dynamic
import(), which Node only accepts as afile://URL.getDeviceDescriptor()also threw forPlatform.OS === 'windows', whichaborts the bridge handshake.
Out-of-tree platform Metro wiring. The harness loads Metro config with a
bare
Metro.loadConfig, so it skips the wiring@react-native/community-cli-plugininstalls for out-of-tree platforms: thereact-nativetoreact-native-windowsresolver redirect, the platform'sInitializeCore, and the extraresolver.platformsentries. Without them awindows bundle cannot resolve
react-native/...and the instance redboxesbefore
HMRClientis registered.The platform package. There was no
@react-native-harness/platform-windows.The GitHub Action.
${{ github.action_path }}is a native path, passedunquoted to
nodeinsideshell: bashsteps, so on a Windows runner bashate the backslashes (
D:\a\_actionsbecameD:a_actions) and every helperscript failed to load.
What changed
@react-native-harness/config: wrap the.mjsconfig path inpathToFileURL()beforeimport(). No behavior change on POSIX.@react-native-harness/runtimeand@react-native-harness/bridge: add awindowscase togetDeviceDescriptor()and'windows'to theDeviceDescriptorplatform union (declared in both packages).@react-native-harness/bundler-metro: read the RN CLI config(
@react-native-community/cli-config, resolved from the project, optionalpeer dependency) and, when a platform declares an
npmPackageName, apply thesame wiring
loadMetroConfigdoes. Gated on that check, so iOS and Androidruns produce a byte-identical Metro config.
@react-native-harness/platform-windows, modeled onplatform-vega.windowsPlatform({ name, packageName, appId?, processName? }). The runnerresolves the package family name from the manifest identity name with
Get-AppxPackage, shell-activates the app by its AUMID, confirms the processstarted, then polls it and emits
app_exitedwhen it goes away.@react-native-harness/platforms: addWindowsAppLaunchOptions.${{ github.action_path }}interpolation,exempt the
windowsplatform from the "app input required" check like web,and derive
HARNESS_PROJECT_ROOTwithpwd -Wso hook subprocesses get anative path under Git Bash. Same quoting fix in the deprecated per-platform
sub-actions.
and Windows in the configuration guide's platform list.
Two incidental fixes surfaced while running the suite on a Windows host:
resource-lock.ts: a heartbeat refresh whose write threw (owner file racing arelease, or a transient FS error) escaped the
setIntervalcallback as anunhandled rejection. It is now swallowed, which matches the existing behavior
for a missed refresh (the lock goes stale and is reclaimed).
RunnerSchema: a barez.object()stripped thegetResourceLockKeyeveryplatform factory sets, so the session always fell back to
<platformId>:<runnerName>. Adding it to the schema means runs againstdifferent devices of the same platform no longer serialize.
A few test path assertions assumed POSIX separators and only failed when the
suite runs from Windows; those were fixed too.
Related Issue
Context
The runner contract (
HarnessPlatform,HarnessPlatformRunnerFactory) ispublic, and
platform-vegaalready shows the shape of an out-of-tree platformpackage, so
platform-windowsfollows that pattern.The Metro change is the one that touches a shared path. It is deliberately
gated: nothing happens unless the RN CLI config reports an out-of-tree platform,
and there is a regression test asserting the Metro config is unchanged in that
case.
@react-native-community/cli-configis resolved from the project ratherthan pinned as a hard dependency, and declared as an optional peer, so projects
without it are unaffected.
This was developed and tested with Nitromodules fork I'm working on https://github.com/StasDoskalenko/nitro. It can land as one
PR or as a stack, whichever is easier to review.
Testing
Unit tests added for each piece: the config reader loading
.mjs/.js/.jsonconfigs,getDeviceDescriptorper platform, the CLI-config parsing andresolver redirect in bundler-metro (plus a regression test that the config is
byte-identical with no out-of-tree platform), the windows runner (AUMID
assembly, not-deployed, process-never-started,
app_exited, abort semantics),and the
getResourceLockKeyschema change.End to end: ran
react-native-harness --harnessRunner windowsagainst adeployed RNW app (mrousavy/nitro's example) at each step. The final state, with
the composite action doing the run, passes on a stock
windows-2025GitHubrunner:
Full suite (
nx run-many -t test) is green on both Linux and Windows.