Skip to content

feat: react-native-windows support - #187

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

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

Conversation

@StasDoskalenko

Copy link
Copy Markdown
Contributor

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

@V3RON

V3RON commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Sounds good! Let's make it like this: metroConfigEnhancer is a path to a module that is then imported when composing the Metro config and evaluated in the context of the project 🙆‍♂️

@StasDoskalenko

Copy link
Copy Markdown
Contributor Author

@V3RON, that makes sense. The PR is here #190 👍
After we merge that pr, I'll rebase my fork and update this PR.

V3RON added a commit that referenced this pull request Sep 2, 2026
## 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.
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