Skip to content

feat: react-native-windows support - #187

Open
StasDoskalenko wants to merge 9 commits into
callstackincubator:mainfrom
StasDoskalenko:main
Open

feat: react-native-windows support#187
StasDoskalenko wants to merge 9 commits into
callstackincubator:mainfrom
StasDoskalenko:main

Conversation

@StasDoskalenko

Copy link
Copy Markdown

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-windows
and a windowsPlatform() runner to rn-harness.config.mjs. No custom runner
script, no metro.config.js changes, no patches.

Four things were missing:

  1. Host support. Loading an ESM rn-harness.config.mjs failed when the
    harness process runs on Windows: the config reader passed a bare absolute
    path to dynamic import(), which Node only accepts as a file:// URL.
    getDeviceDescriptor() also threw for Platform.OS === 'windows', which
    aborts the bridge handshake.

  2. 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-plugin installs for out-of-tree platforms: the
    react-native to react-native-windows resolver redirect, the platform's
    InitializeCore, and the extra resolver.platforms entries. Without them a
    windows bundle cannot resolve react-native/... and the instance redboxes
    before HMRClient is registered.

  3. The platform package. There was no @react-native-harness/platform-windows.

  4. The GitHub Action. ${{ github.action_path }} is a native path, passed
    unquoted to node inside shell: bash steps, so on a Windows runner bash
    ate the backslashes (D:\a\_actions became D:a_actions) and every helper
    script failed to load.

What changed

  • @react-native-harness/config: wrap the .mjs config path in
    pathToFileURL() before import(). No behavior change on POSIX.
  • @react-native-harness/runtime and @react-native-harness/bridge: add a
    windows case to getDeviceDescriptor() and 'windows' to the
    DeviceDescriptor platform union (declared in both packages).
  • @react-native-harness/bundler-metro: read the RN CLI config
    (@react-native-community/cli-config, resolved from the project, optional
    peer dependency) and, when a platform declares an npmPackageName, apply the
    same wiring loadMetroConfig does. Gated on that check, so iOS and Android
    runs produce a byte-identical Metro config.
  • New @react-native-harness/platform-windows, modeled on platform-vega.
    windowsPlatform({ name, packageName, appId?, processName? }). The runner
    resolves the package family name from the manifest identity name with
    Get-AppxPackage, shell-activates the app by its AUMID, confirms the process
    started, then polls it and emits app_exited when it goes away.
  • @react-native-harness/platforms: add WindowsAppLaunchOptions.
  • The composite action: quote every ${{ github.action_path }} interpolation,
    exempt the windows platform from the "app input required" check like web,
    and derive HARNESS_PROJECT_ROOT with pwd -W so hook subprocesses get a
    native path under Git Bash. Same quoting fix in the deprecated per-platform
    sub-actions.
  • Docs: a Windows platform guide, a "Windows in CI" section in the CI/CD guide,
    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 a
    release, or a transient FS error) escaped the setInterval callback as an
    unhandled rejection. It is now swallowed, which matches the existing behavior
    for a missed refresh (the lock goes stale and is reclaimed).
  • RunnerSchema: a bare z.object() stripped the getResourceLockKey every
    platform factory sets, so the session always fell back to
    <platformId>:<runnerName>. Adding it to the schema means runs against
    different 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) is
public, and platform-vega already shows the shape of an out-of-tree platform
package, so platform-windows follows 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-config is resolved from the project rather
than 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 /
.json configs, getDeviceDescriptor per platform, the CLI-config parsing and
resolver 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 getResourceLockKey schema change.

End to end: ran react-native-harness --harnessRunner windows against a
deployed RNW app (mrousavy/nitro's example) at each step. The final state, with
the composite action doing the run, passes on a stock windows-2025 GitHub
runner:

Load React Native Harness configuration   ok
Run E2E tests: react-native-harness --harnessRunner windows windows-smoke
  HARNESS Runner windows ready
  Test Suites: 1 passed, 1 total
  Tests:       2 passed, 2 total

Full suite (nx run-many -t test) is green on both Linux and Windows.

StasDoskalenko and others added 7 commits August 28, 2026 16:16
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>
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

@StasDoskalenko is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread actions/android/action.yml Outdated
HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }}
run: |
node ${{ github.action_path }}/../shared/index.cjs
node "${{ github.action_path }}/../shared/index.cjs"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unfortunately windows runners require ""

@V3RON

V3RON commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Hey @StasDoskalenko!
Thanks for the contribution. I've briefly gone through it and started wondering if we should somehow move the Metro config augmentations to the platform packages so that when somebody uses Windows, only the changes related to it are applied.

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?

StasDoskalenko and others added 2 commits August 31, 2026 10:53
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>
@StasDoskalenko

Copy link
Copy Markdown
Author

@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 @react-native-community/cli-config lookup completely, since a platform package already knows its own npm name (react-native-windows and friends). If macOS or anything else shows up later with its own quirks, it just handles them itself.

Couple of things to consider:

The enhancer probably can't be a plain function hanging off the platform object. RunnerSchema is a z.object(), so it strips anything it doesn't know about during config parsing, and the function would be gone before the session ever sees it. That's the same thing that caught getResourceLockKey. Feels like the cleanest fit is to follow how runner and cli already work: a metroConfigEnhancer string pointing at a module, and withRnHarness imports it and calls it with the patched config plus a bit of context (projectRoot mainly, so it can resolve the platform's InitializeCore).

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 withRnHarness. Nice side effect: the enhancer would only run for the runner you actually selected, instead of my current version which applies the wiring for every out-of-tree platform it can find.

The resolver redirect and the resolver.platforms additions move over pretty much untouched. getModulesRunBeforeMainModule is the only one that needs a little care so the enhancer composes with whatever's already there, but since Metro only keeps run-before modules that are actually in the graph, it stays a no-op for iOS and Android anyway.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants