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? |
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.