@react-native-harness/platform-windows 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +76 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +24 -0
- package/dist/factory.d.ts +4 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/metro-config-enhancer.d.ts +4 -0
- package/dist/metro-config-enhancer.d.ts.map +1 -0
- package/dist/metro-config-enhancer.js +79 -0
- package/dist/pwsh.d.ts +27 -0
- package/dist/pwsh.d.ts.map +1 -0
- package/dist/pwsh.js +57 -0
- package/dist/runner.d.ts +6 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +128 -0
- package/eslint.config.mjs +23 -0
- package/package.json +35 -0
- package/src/config.ts +36 -0
- package/src/factory.ts +13 -0
- package/src/index.ts +6 -0
- package/src/metro-config-enhancer.ts +112 -0
- package/src/pwsh.ts +73 -0
- package/src/runner.ts +173 -0
- package/tsconfig.json +22 -0
- package/tsconfig.lib.json +27 -0
- package/vite.config.ts +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Callstack
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
[![mit licence][license-badge]][license]
|
|
4
|
+
[![npm downloads][npm-downloads-badge]][npm-downloads]
|
|
5
|
+
[![Chat][chat-badge]][chat]
|
|
6
|
+
[![PRs Welcome][prs-welcome-badge]][prs-welcome]
|
|
7
|
+
|
|
8
|
+
React Native Windows platform for React Native Harness — runs your harness tests against a deployed React Native Windows app.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install --save-dev @react-native-harness/platform-windows
|
|
14
|
+
# or
|
|
15
|
+
pnpm add -D @react-native-harness/platform-windows
|
|
16
|
+
# or
|
|
17
|
+
yarn add -D @react-native-harness/platform-windows
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Add the Windows platform to your `rn-harness.config.mjs`:
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import { windowsPlatform } from '@react-native-harness/platform-windows';
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
entryPoint: './index.js',
|
|
29
|
+
appRegistryComponentName: 'MyApp',
|
|
30
|
+
runners: [
|
|
31
|
+
windowsPlatform({
|
|
32
|
+
name: 'windows',
|
|
33
|
+
// Package.appxmanifest Identity/@Name
|
|
34
|
+
packageName: 'MyApp',
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Deploy the app before running the harness — the runner launches an already
|
|
41
|
+
installed package, it does not build:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx react-native run-windows --arch x64 --no-launch --no-packager
|
|
45
|
+
npx react-native-harness --harnessRunner windows
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
### `windowsPlatform(config)`
|
|
51
|
+
|
|
52
|
+
**Parameters:**
|
|
53
|
+
|
|
54
|
+
- `config.name` — unique name for the runner.
|
|
55
|
+
- `config.packageName` — the app's `Identity/@Name` from `Package.appxmanifest`. Used to look the deployed package up with `Get-AppxPackage`.
|
|
56
|
+
- `config.appId` — the app's `Application/@Id` from `Package.appxmanifest`. Combined with the package family name into the AUMID used to launch the app. Defaults to `App` (the React Native Windows template value).
|
|
57
|
+
- `config.processName` — the app's process name (without `.exe`), for tracking whether it is still running. Defaults to `packageName`.
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Windows 10/11 with the app already deployed (`react-native run-windows`).
|
|
62
|
+
- The harness Metro server reachable at the app's configured bundle URL (`http://localhost:8081` by default).
|
|
63
|
+
|
|
64
|
+
## Made with ❤️ at Callstack
|
|
65
|
+
|
|
66
|
+
`react-native-harness` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi!
|
|
67
|
+
|
|
68
|
+
[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love
|
|
69
|
+
[license-badge]: https://img.shields.io/npm/l/react-native-harness?style=for-the-badge
|
|
70
|
+
[license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE
|
|
71
|
+
[npm-downloads-badge]: https://img.shields.io/npm/dm/react-native-harness?style=for-the-badge
|
|
72
|
+
[npm-downloads]: https://www.npmjs.com/package/react-native-harness
|
|
73
|
+
[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge
|
|
74
|
+
[prs-welcome]: ./CONTRIBUTING.md
|
|
75
|
+
[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge
|
|
76
|
+
[chat]: https://discord.gg/xgGt7KAjxv
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const WindowsAppLaunchOptionsSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
3
|
+
export declare const WindowsPlatformConfigSchema: z.ZodObject<{
|
|
4
|
+
name: z.ZodString;
|
|
5
|
+
/**
|
|
6
|
+
* The app's `Identity/@Name` from `Package.appxmanifest` — e.g.
|
|
7
|
+
* `ReactNativeNitroExample`. Used to look the deployed package up with
|
|
8
|
+
* `Get-AppxPackage`.
|
|
9
|
+
*/
|
|
10
|
+
packageName: z.ZodString;
|
|
11
|
+
/**
|
|
12
|
+
* The app's `Application/@Id` from `Package.appxmanifest`. Combined with the
|
|
13
|
+
* package family name into the AUMID used to launch the app. Defaults to
|
|
14
|
+
* `App`, which is what the React Native Windows template generates.
|
|
15
|
+
*/
|
|
16
|
+
appId: z.ZodDefault<z.ZodOptional<z.ZodString>>;
|
|
17
|
+
/**
|
|
18
|
+
* The name of the app's process (without `.exe`), for tracking whether it is
|
|
19
|
+
* still running. Defaults to `packageName`, which is correct for the RNW
|
|
20
|
+
* template.
|
|
21
|
+
*/
|
|
22
|
+
processName: z.ZodOptional<z.ZodString>;
|
|
23
|
+
appLaunchOptions: z.ZodOptional<z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>>;
|
|
24
|
+
}, "strip", z.ZodTypeAny, {
|
|
25
|
+
name: string;
|
|
26
|
+
packageName: string;
|
|
27
|
+
appId: string;
|
|
28
|
+
processName?: string | undefined;
|
|
29
|
+
appLaunchOptions?: {} | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
name: string;
|
|
32
|
+
packageName: string;
|
|
33
|
+
appId?: string | undefined;
|
|
34
|
+
processName?: string | undefined;
|
|
35
|
+
appLaunchOptions?: {} | undefined;
|
|
36
|
+
}>;
|
|
37
|
+
export type WindowsAppLaunchOptions = z.infer<typeof WindowsAppLaunchOptionsSchema>;
|
|
38
|
+
export type WindowsPlatformConfig = z.infer<typeof WindowsPlatformConfigSchema>;
|
|
39
|
+
/** The `WindowsPlatformConfig` before Zod applies defaults (e.g. `appId`). */
|
|
40
|
+
export type WindowsPlatformConfigInput = z.input<typeof WindowsPlatformConfigSchema>;
|
|
41
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,6BAA6B,gDAAe,CAAC;AAE1D,eAAO,MAAM,2BAA2B;;IAEtC;;;;OAIG;;IAEH;;;;OAIG;;IAEH;;;;OAIG;;;;;;;;;;;;;;;EAGH,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAC3C,OAAO,6BAA6B,CACrC,CAAC;AACF,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAC;AAEhF,8EAA8E;AAC9E,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAC9C,OAAO,2BAA2B,CACnC,CAAC"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const WindowsAppLaunchOptionsSchema = z.object({});
|
|
3
|
+
export const WindowsPlatformConfigSchema = z.object({
|
|
4
|
+
name: z.string().min(1, 'Name is required'),
|
|
5
|
+
/**
|
|
6
|
+
* The app's `Identity/@Name` from `Package.appxmanifest` — e.g.
|
|
7
|
+
* `ReactNativeNitroExample`. Used to look the deployed package up with
|
|
8
|
+
* `Get-AppxPackage`.
|
|
9
|
+
*/
|
|
10
|
+
packageName: z.string().min(1, 'packageName is required'),
|
|
11
|
+
/**
|
|
12
|
+
* The app's `Application/@Id` from `Package.appxmanifest`. Combined with the
|
|
13
|
+
* package family name into the AUMID used to launch the app. Defaults to
|
|
14
|
+
* `App`, which is what the React Native Windows template generates.
|
|
15
|
+
*/
|
|
16
|
+
appId: z.string().min(1).optional().default('App'),
|
|
17
|
+
/**
|
|
18
|
+
* The name of the app's process (without `.exe`), for tracking whether it is
|
|
19
|
+
* still running. Defaults to `packageName`, which is correct for the RNW
|
|
20
|
+
* template.
|
|
21
|
+
*/
|
|
22
|
+
processName: z.string().min(1).optional(),
|
|
23
|
+
appLaunchOptions: WindowsAppLaunchOptionsSchema.optional(),
|
|
24
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { HarnessPlatform } from '@react-native-harness/platforms';
|
|
2
|
+
import type { WindowsPlatformConfigInput } from './config.js';
|
|
3
|
+
export declare const windowsPlatform: (config: WindowsPlatformConfigInput) => HarnessPlatform<WindowsPlatformConfigInput>;
|
|
4
|
+
//# sourceMappingURL=factory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../src/factory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAE9D,eAAO,MAAM,eAAe,GAC1B,QAAQ,0BAA0B,KACjC,eAAe,CAAC,0BAA0B,CAO3C,CAAC"}
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const windowsPlatform = (config) => ({
|
|
2
|
+
name: config.name,
|
|
3
|
+
config,
|
|
4
|
+
runner: import.meta.resolve('./runner.js'),
|
|
5
|
+
metroConfigEnhancer: import.meta.resolve('./metro-config-enhancer.js'),
|
|
6
|
+
platformId: 'windows',
|
|
7
|
+
getResourceLockKey: () => `windows:${config.packageName}`,
|
|
8
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,YAAY,EACV,qBAAqB,EACrB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { windowsPlatform } from './factory.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metro-config-enhancer.d.ts","sourceRoot":"","sources":["../src/metro-config-enhancer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,mBAAmB,EAEpB,MAAM,qCAAqC,CAAC;AAqE7C,QAAA,MAAM,kBAAkB,EAAE,mBAmCzB,CAAC;AAEF,eAAe,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { logger } from '@react-native-harness/tools';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const log = logger.child('platform-windows');
|
|
5
|
+
const RNW_PACKAGE = 'react-native-windows';
|
|
6
|
+
/**
|
|
7
|
+
* Redirects `react-native` / `react-native/*` imports to `react-native-windows`
|
|
8
|
+
* when Metro is bundling for the `windows` platform, delegating to whatever
|
|
9
|
+
* `resolveRequest` the harness has already composed.
|
|
10
|
+
*
|
|
11
|
+
* `react-native start` installs this via `@react-native/community-cli-plugin`'s
|
|
12
|
+
* `reactNativePlatformResolver`, keyed off the platform's `npmPackageName` in
|
|
13
|
+
* the React Native CLI config. The harness loads Metro's config directly and
|
|
14
|
+
* never runs that plugin, so the platform package supplies the redirect itself.
|
|
15
|
+
*/
|
|
16
|
+
const withWindowsPackageRedirect = (next) => {
|
|
17
|
+
const passThrough = next ?? ((ctx, name, plat) => ctx.resolveRequest(ctx, name, plat));
|
|
18
|
+
return (context, moduleName, platform) => {
|
|
19
|
+
if (platform !== 'windows') {
|
|
20
|
+
return passThrough(context, moduleName, platform);
|
|
21
|
+
}
|
|
22
|
+
let redirected = moduleName;
|
|
23
|
+
if (moduleName === 'react-native') {
|
|
24
|
+
redirected = RNW_PACKAGE;
|
|
25
|
+
}
|
|
26
|
+
else if (moduleName.startsWith('react-native/')) {
|
|
27
|
+
redirected = `${RNW_PACKAGE}/${moduleName.slice('react-native/'.length)}`;
|
|
28
|
+
}
|
|
29
|
+
return passThrough(context, redirected, platform);
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Resolves `react-native-windows/Libraries/Core/InitializeCore` from the
|
|
34
|
+
* project. Without it running before the entry point a `windows` bundle never
|
|
35
|
+
* calls `setUpBatchedBridge`, so `HMRClient` is not a registered callable
|
|
36
|
+
* module and React Native Windows redboxes the instance before the harness can
|
|
37
|
+
* attach. Metro only emits a `require()` for a run-before module that ends up
|
|
38
|
+
* in the graph, so this is inert for any non-Windows bundle.
|
|
39
|
+
*/
|
|
40
|
+
const resolveInitializeCore = (projectRoot) => {
|
|
41
|
+
const specifier = `${RNW_PACKAGE}/Libraries/Core/InitializeCore`;
|
|
42
|
+
try {
|
|
43
|
+
return require.resolve(specifier, { paths: [projectRoot] });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
log.warn('could not resolve %s from %s; Windows bundles may fail to initialize', specifier, projectRoot);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const enhanceMetroConfig = (metroConfig, { projectRoot }) => {
|
|
51
|
+
const initializeCore = resolveInitializeCore(projectRoot);
|
|
52
|
+
const existingRunBeforeMainModule = metroConfig.serializer?.getModulesRunBeforeMainModule;
|
|
53
|
+
return {
|
|
54
|
+
...metroConfig,
|
|
55
|
+
resolver: {
|
|
56
|
+
...metroConfig.resolver,
|
|
57
|
+
platforms: [
|
|
58
|
+
...new Set([
|
|
59
|
+
...(metroConfig.resolver?.platforms ?? ['ios', 'android']),
|
|
60
|
+
'windows',
|
|
61
|
+
'native',
|
|
62
|
+
]),
|
|
63
|
+
],
|
|
64
|
+
resolveRequest: withWindowsPackageRedirect(metroConfig.resolver?.resolveRequest ?? undefined),
|
|
65
|
+
},
|
|
66
|
+
serializer: {
|
|
67
|
+
...metroConfig.serializer,
|
|
68
|
+
...(initializeCore
|
|
69
|
+
? {
|
|
70
|
+
getModulesRunBeforeMainModule: (entryPoint) => [
|
|
71
|
+
...(existingRunBeforeMainModule?.(entryPoint) ?? []),
|
|
72
|
+
initializeCore,
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
export default enhanceMetroConfig;
|
package/dist/pwsh.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a PowerShell snippet non-interactively and returns its trimmed stdout.
|
|
3
|
+
* `-NoProfile` keeps it fast and hermetic; `-NonInteractive` makes sure it
|
|
4
|
+
* never blocks on a prompt.
|
|
5
|
+
*/
|
|
6
|
+
export declare const runPowerShell: (script: string) => Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the PackageFamilyName of a deployed MSIX package from its identity
|
|
9
|
+
* name (`Package.appxmanifest` `Identity/@Name`). Returns `null` when the app
|
|
10
|
+
* is not deployed.
|
|
11
|
+
*/
|
|
12
|
+
export declare const getPackageFamilyName: (identityName: string) => Promise<string | null>;
|
|
13
|
+
/** Whether at least one process with the given name (no `.exe`) is running. */
|
|
14
|
+
export declare const isProcessRunning: (processName: string) => Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Launches a deployed MSIX app by its AUMID
|
|
17
|
+
* (`<PackageFamilyName>!<Application Id>`).
|
|
18
|
+
*
|
|
19
|
+
* `explorer.exe shell:AppsFolder\<aumid>` is the reliable activation path, but
|
|
20
|
+
* `explorer.exe` almost always exits non-zero even on success, so its failure
|
|
21
|
+
* is swallowed — the caller confirms the app came up by polling for its
|
|
22
|
+
* process.
|
|
23
|
+
*/
|
|
24
|
+
export declare const launchAppByAumid: (aumid: string) => Promise<void>;
|
|
25
|
+
/** Force-terminates every process with the given name. Safe to call when none exist. */
|
|
26
|
+
export declare const stopProcess: (processName: string) => Promise<void>;
|
|
27
|
+
//# sourceMappingURL=pwsh.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pwsh.d.ts","sourceRoot":"","sources":["../src/pwsh.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAU,QAAQ,MAAM,KAAG,OAAO,CAAC,MAAM,CAOlE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAC/B,cAAc,MAAM,KACnB,OAAO,CAAC,MAAM,GAAG,IAAI,CAKvB,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAC3B,aAAa,MAAM,KAClB,OAAO,CAAC,OAAO,CASjB,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO,MAAM,KAAG,OAAO,CAAC,IAAI,CAQlE,CAAC;AAEF,wFAAwF;AACxF,eAAO,MAAM,WAAW,GAAU,aAAa,MAAM,KAAG,OAAO,CAAC,IAAI,CAQnE,CAAC"}
|
package/dist/pwsh.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawn } from '@react-native-harness/tools';
|
|
2
|
+
/**
|
|
3
|
+
* Runs a PowerShell snippet non-interactively and returns its trimmed stdout.
|
|
4
|
+
* `-NoProfile` keeps it fast and hermetic; `-NonInteractive` makes sure it
|
|
5
|
+
* never blocks on a prompt.
|
|
6
|
+
*/
|
|
7
|
+
export const runPowerShell = async (script) => {
|
|
8
|
+
const { stdout } = await spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true });
|
|
9
|
+
return stdout.trim();
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the PackageFamilyName of a deployed MSIX package from its identity
|
|
13
|
+
* name (`Package.appxmanifest` `Identity/@Name`). Returns `null` when the app
|
|
14
|
+
* is not deployed.
|
|
15
|
+
*/
|
|
16
|
+
export const getPackageFamilyName = async (identityName) => {
|
|
17
|
+
const pfn = await runPowerShell(`(Get-AppxPackage -Name '${identityName}' | Select-Object -First 1).PackageFamilyName`);
|
|
18
|
+
return pfn === '' ? null : pfn;
|
|
19
|
+
};
|
|
20
|
+
/** Whether at least one process with the given name (no `.exe`) is running. */
|
|
21
|
+
export const isProcessRunning = async (processName) => {
|
|
22
|
+
try {
|
|
23
|
+
const count = await runPowerShell(`(Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Measure-Object).Count`);
|
|
24
|
+
return Number(count) > 0;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Launches a deployed MSIX app by its AUMID
|
|
32
|
+
* (`<PackageFamilyName>!<Application Id>`).
|
|
33
|
+
*
|
|
34
|
+
* `explorer.exe shell:AppsFolder\<aumid>` is the reliable activation path, but
|
|
35
|
+
* `explorer.exe` almost always exits non-zero even on success, so its failure
|
|
36
|
+
* is swallowed — the caller confirms the app came up by polling for its
|
|
37
|
+
* process.
|
|
38
|
+
*/
|
|
39
|
+
export const launchAppByAumid = async (aumid) => {
|
|
40
|
+
try {
|
|
41
|
+
await spawn('explorer.exe', [`shell:AppsFolder\\${aumid}`], {
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Expected: explorer.exe reports a non-zero exit even on success.
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** Force-terminates every process with the given name. Safe to call when none exist. */
|
|
50
|
+
export const stopProcess = async (processName) => {
|
|
51
|
+
try {
|
|
52
|
+
await runPowerShell(`Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Stop-Process -Force`);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Nothing to stop, or it exited between the query and the kill.
|
|
56
|
+
}
|
|
57
|
+
};
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type HarnessPlatformRunnerFactory } from '@react-native-harness/platforms';
|
|
2
|
+
import type { Config as HarnessConfig } from '@react-native-harness/config';
|
|
3
|
+
import { type WindowsPlatformConfigInput } from './config.js';
|
|
4
|
+
declare const getWindowsRunner: HarnessPlatformRunnerFactory<WindowsPlatformConfigInput, HarnessConfig>;
|
|
5
|
+
export default getWindowsRunner;
|
|
6
|
+
//# sourceMappingURL=runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,4BAA4B,EAClC,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE5E,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,aAAa,CAAC;AA+BrB,QAAA,MAAM,gBAAgB,EAAE,4BAA4B,CAClD,0BAA0B,EAC1B,aAAa,CA6Hd,CAAC;AAEF,eAAe,gBAAgB,CAAC"}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createAppSessionEmitter, AppNotInstalledError, } from '@react-native-harness/platforms';
|
|
2
|
+
import { logger } from '@react-native-harness/tools';
|
|
3
|
+
import { WindowsPlatformConfigSchema, } from './config.js';
|
|
4
|
+
import { getPackageFamilyName, isProcessRunning, launchAppByAumid, stopProcess, } from './pwsh.js';
|
|
5
|
+
const log = logger.child('platform-windows');
|
|
6
|
+
const APP_EXIT_POLL_INTERVAL_MS = 1000;
|
|
7
|
+
const APP_START_POLL_INTERVAL_MS = 400;
|
|
8
|
+
const APP_START_POLL_ATTEMPTS = 15;
|
|
9
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
10
|
+
if (signal.aborted) {
|
|
11
|
+
reject(signal.reason);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const timer = setTimeout(() => {
|
|
15
|
+
signal.removeEventListener('abort', onAbort);
|
|
16
|
+
resolve();
|
|
17
|
+
}, ms);
|
|
18
|
+
const onAbort = () => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
reject(signal.reason);
|
|
21
|
+
};
|
|
22
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
23
|
+
});
|
|
24
|
+
const getWindowsRunner = async (config, _harnessConfig, init) => {
|
|
25
|
+
void _harnessConfig;
|
|
26
|
+
const parsedConfig = WindowsPlatformConfigSchema.parse(config);
|
|
27
|
+
const { packageName, appId } = parsedConfig;
|
|
28
|
+
const processName = parsedConfig.processName ?? packageName;
|
|
29
|
+
const packageFamilyName = await getPackageFamilyName(packageName);
|
|
30
|
+
if (packageFamilyName == null) {
|
|
31
|
+
throw new AppNotInstalledError(packageName, 'this machine');
|
|
32
|
+
}
|
|
33
|
+
const aumid = `${packageFamilyName}!${appId}`;
|
|
34
|
+
log.debug('resolved AUMID %s for package %s', aumid, packageName);
|
|
35
|
+
return {
|
|
36
|
+
createAppSession: async () => {
|
|
37
|
+
// Clean slate: never attach to an instance left over from a previous run.
|
|
38
|
+
await stopProcess(processName);
|
|
39
|
+
await launchAppByAumid(aumid);
|
|
40
|
+
// `explorer.exe` returns before the app is up (and lies about its exit
|
|
41
|
+
// code), so confirm the process actually started. `init.signal` cancels
|
|
42
|
+
// this finite readiness wait; it is not a disposal signal.
|
|
43
|
+
let started = false;
|
|
44
|
+
for (let attempt = 0; attempt < APP_START_POLL_ATTEMPTS; attempt += 1) {
|
|
45
|
+
if (await isProcessRunning(processName)) {
|
|
46
|
+
started = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
await delay(APP_START_POLL_INTERVAL_MS, init.signal);
|
|
50
|
+
}
|
|
51
|
+
if (!started) {
|
|
52
|
+
await stopProcess(processName);
|
|
53
|
+
throw new Error(`The Windows app '${processName}' did not start after launching ${aumid}. ` +
|
|
54
|
+
`Deploy it first, e.g. \`npx react-native run-windows --arch x64 --no-launch\`.`);
|
|
55
|
+
}
|
|
56
|
+
const emitter = createAppSessionEmitter();
|
|
57
|
+
let state = { status: 'running' };
|
|
58
|
+
let disposed = false;
|
|
59
|
+
let stopPolling = false;
|
|
60
|
+
let pollDelayTimeout = null;
|
|
61
|
+
let resolvePollDelay = null;
|
|
62
|
+
// Unlike a raced delay() loser (which is fine to just discard), this
|
|
63
|
+
// wait is directly `await`ed by pollTask with nothing else racing it,
|
|
64
|
+
// so cancelling it must also resolve the promise immediately —
|
|
65
|
+
// otherwise dispose() blocks on `await pollTask` for up to
|
|
66
|
+
// APP_EXIT_POLL_INTERVAL_MS instead of returning right away.
|
|
67
|
+
const waitForNextPoll = () => new Promise((resolve) => {
|
|
68
|
+
resolvePollDelay = () => {
|
|
69
|
+
resolvePollDelay = null;
|
|
70
|
+
pollDelayTimeout = null;
|
|
71
|
+
resolve();
|
|
72
|
+
};
|
|
73
|
+
pollDelayTimeout = setTimeout(() => {
|
|
74
|
+
resolvePollDelay?.();
|
|
75
|
+
}, APP_EXIT_POLL_INTERVAL_MS);
|
|
76
|
+
});
|
|
77
|
+
const cancelPendingPollDelay = () => {
|
|
78
|
+
if (pollDelayTimeout) {
|
|
79
|
+
clearTimeout(pollDelayTimeout);
|
|
80
|
+
pollDelayTimeout = null;
|
|
81
|
+
}
|
|
82
|
+
resolvePollDelay?.();
|
|
83
|
+
};
|
|
84
|
+
const pollTask = (async () => {
|
|
85
|
+
while (!stopPolling) {
|
|
86
|
+
if (!(await isProcessRunning(processName))) {
|
|
87
|
+
if (!disposed && state.status === 'running') {
|
|
88
|
+
state = {
|
|
89
|
+
status: 'exited',
|
|
90
|
+
occurredAt: Date.now(),
|
|
91
|
+
reason: 'process-gone',
|
|
92
|
+
};
|
|
93
|
+
emitter.emit({ type: 'app_exited' });
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (stopPolling) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await waitForNextPoll();
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
const session = {
|
|
104
|
+
dispose: async () => {
|
|
105
|
+
if (disposed) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
disposed = true;
|
|
109
|
+
stopPolling = true;
|
|
110
|
+
cancelPendingPollDelay();
|
|
111
|
+
state = { status: 'disposed', occurredAt: Date.now() };
|
|
112
|
+
emitter.clear();
|
|
113
|
+
await stopProcess(processName);
|
|
114
|
+
await pollTask;
|
|
115
|
+
},
|
|
116
|
+
getState: async () => state,
|
|
117
|
+
getLogs: () => [],
|
|
118
|
+
addListener: emitter.addListener,
|
|
119
|
+
removeListener: emitter.removeListener,
|
|
120
|
+
};
|
|
121
|
+
return session;
|
|
122
|
+
},
|
|
123
|
+
dispose: async () => {
|
|
124
|
+
await stopProcess(processName);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
export default getWindowsRunner;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import baseConfig from '../../eslint.config.mjs';
|
|
2
|
+
|
|
3
|
+
export default [
|
|
4
|
+
...baseConfig,
|
|
5
|
+
{
|
|
6
|
+
files: ['**/*.json'],
|
|
7
|
+
rules: {
|
|
8
|
+
'@nx/dependency-checks': [
|
|
9
|
+
'error',
|
|
10
|
+
{
|
|
11
|
+
ignoredDependencies: ['vite', 'vitest'],
|
|
12
|
+
ignoredFiles: [
|
|
13
|
+
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
|
|
14
|
+
'{projectRoot}/src/**/__tests__/**',
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
languageOptions: {
|
|
20
|
+
parser: await import('jsonc-eslint-parser'),
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@react-native-harness/platform-windows",
|
|
3
|
+
"description": "React Native Windows platform for React Native Harness",
|
|
4
|
+
"version": "1.5.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
".": {
|
|
12
|
+
"development": "./src/index.ts",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"zod": "^3.25.67",
|
|
20
|
+
"tslib": "^2.3.0",
|
|
21
|
+
"@react-native-harness/bundler-metro": "1.5.0",
|
|
22
|
+
"@react-native-harness/config": "1.5.0",
|
|
23
|
+
"@react-native-harness/tools": "1.5.0",
|
|
24
|
+
"@react-native-harness/platforms": "1.5.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"metro-config": "*",
|
|
28
|
+
"metro-resolver": "*"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"metro-config": "*",
|
|
32
|
+
"metro-resolver": "*"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const WindowsAppLaunchOptionsSchema = z.object({});
|
|
4
|
+
|
|
5
|
+
export const WindowsPlatformConfigSchema = z.object({
|
|
6
|
+
name: z.string().min(1, 'Name is required'),
|
|
7
|
+
/**
|
|
8
|
+
* The app's `Identity/@Name` from `Package.appxmanifest` — e.g.
|
|
9
|
+
* `ReactNativeNitroExample`. Used to look the deployed package up with
|
|
10
|
+
* `Get-AppxPackage`.
|
|
11
|
+
*/
|
|
12
|
+
packageName: z.string().min(1, 'packageName is required'),
|
|
13
|
+
/**
|
|
14
|
+
* The app's `Application/@Id` from `Package.appxmanifest`. Combined with the
|
|
15
|
+
* package family name into the AUMID used to launch the app. Defaults to
|
|
16
|
+
* `App`, which is what the React Native Windows template generates.
|
|
17
|
+
*/
|
|
18
|
+
appId: z.string().min(1).optional().default('App'),
|
|
19
|
+
/**
|
|
20
|
+
* The name of the app's process (without `.exe`), for tracking whether it is
|
|
21
|
+
* still running. Defaults to `packageName`, which is correct for the RNW
|
|
22
|
+
* template.
|
|
23
|
+
*/
|
|
24
|
+
processName: z.string().min(1).optional(),
|
|
25
|
+
appLaunchOptions: WindowsAppLaunchOptionsSchema.optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type WindowsAppLaunchOptions = z.infer<
|
|
29
|
+
typeof WindowsAppLaunchOptionsSchema
|
|
30
|
+
>;
|
|
31
|
+
export type WindowsPlatformConfig = z.infer<typeof WindowsPlatformConfigSchema>;
|
|
32
|
+
|
|
33
|
+
/** The `WindowsPlatformConfig` before Zod applies defaults (e.g. `appId`). */
|
|
34
|
+
export type WindowsPlatformConfigInput = z.input<
|
|
35
|
+
typeof WindowsPlatformConfigSchema
|
|
36
|
+
>;
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { HarnessPlatform } from '@react-native-harness/platforms';
|
|
2
|
+
import type { WindowsPlatformConfigInput } from './config.js';
|
|
3
|
+
|
|
4
|
+
export const windowsPlatform = (
|
|
5
|
+
config: WindowsPlatformConfigInput
|
|
6
|
+
): HarnessPlatform<WindowsPlatformConfigInput> => ({
|
|
7
|
+
name: config.name,
|
|
8
|
+
config,
|
|
9
|
+
runner: import.meta.resolve('./runner.js'),
|
|
10
|
+
metroConfigEnhancer: import.meta.resolve('./metro-config-enhancer.js'),
|
|
11
|
+
platformId: 'windows',
|
|
12
|
+
getResourceLockKey: () => `windows:${config.packageName}`,
|
|
13
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { logger } from '@react-native-harness/tools';
|
|
3
|
+
import type {
|
|
4
|
+
MetroConfigEnhancer,
|
|
5
|
+
MetroConfigEnhancerContext,
|
|
6
|
+
} from '@react-native-harness/bundler-metro';
|
|
7
|
+
import type { MetroConfig } from 'metro-config';
|
|
8
|
+
import type { CustomResolutionContext, Resolution } from 'metro-resolver';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const log = logger.child('platform-windows');
|
|
12
|
+
|
|
13
|
+
const RNW_PACKAGE = 'react-native-windows';
|
|
14
|
+
|
|
15
|
+
type MetroResolveRequest = (
|
|
16
|
+
context: CustomResolutionContext,
|
|
17
|
+
moduleName: string,
|
|
18
|
+
platform: string | null
|
|
19
|
+
) => Resolution;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Redirects `react-native` / `react-native/*` imports to `react-native-windows`
|
|
23
|
+
* when Metro is bundling for the `windows` platform, delegating to whatever
|
|
24
|
+
* `resolveRequest` the harness has already composed.
|
|
25
|
+
*
|
|
26
|
+
* `react-native start` installs this via `@react-native/community-cli-plugin`'s
|
|
27
|
+
* `reactNativePlatformResolver`, keyed off the platform's `npmPackageName` in
|
|
28
|
+
* the React Native CLI config. The harness loads Metro's config directly and
|
|
29
|
+
* never runs that plugin, so the platform package supplies the redirect itself.
|
|
30
|
+
*/
|
|
31
|
+
const withWindowsPackageRedirect = (
|
|
32
|
+
next: MetroResolveRequest | undefined
|
|
33
|
+
): MetroResolveRequest => {
|
|
34
|
+
const passThrough: MetroResolveRequest =
|
|
35
|
+
next ?? ((ctx, name, plat) => ctx.resolveRequest(ctx, name, plat));
|
|
36
|
+
|
|
37
|
+
return (context, moduleName, platform) => {
|
|
38
|
+
if (platform !== 'windows') {
|
|
39
|
+
return passThrough(context, moduleName, platform);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let redirected = moduleName;
|
|
43
|
+
if (moduleName === 'react-native') {
|
|
44
|
+
redirected = RNW_PACKAGE;
|
|
45
|
+
} else if (moduleName.startsWith('react-native/')) {
|
|
46
|
+
redirected = `${RNW_PACKAGE}/${moduleName.slice('react-native/'.length)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return passThrough(context, redirected, platform);
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves `react-native-windows/Libraries/Core/InitializeCore` from the
|
|
55
|
+
* project. Without it running before the entry point a `windows` bundle never
|
|
56
|
+
* calls `setUpBatchedBridge`, so `HMRClient` is not a registered callable
|
|
57
|
+
* module and React Native Windows redboxes the instance before the harness can
|
|
58
|
+
* attach. Metro only emits a `require()` for a run-before module that ends up
|
|
59
|
+
* in the graph, so this is inert for any non-Windows bundle.
|
|
60
|
+
*/
|
|
61
|
+
const resolveInitializeCore = (projectRoot: string): string | null => {
|
|
62
|
+
const specifier = `${RNW_PACKAGE}/Libraries/Core/InitializeCore`;
|
|
63
|
+
try {
|
|
64
|
+
return require.resolve(specifier, { paths: [projectRoot] });
|
|
65
|
+
} catch {
|
|
66
|
+
log.warn(
|
|
67
|
+
'could not resolve %s from %s; Windows bundles may fail to initialize',
|
|
68
|
+
specifier,
|
|
69
|
+
projectRoot
|
|
70
|
+
);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const enhanceMetroConfig: MetroConfigEnhancer = (
|
|
76
|
+
metroConfig: MetroConfig,
|
|
77
|
+
{ projectRoot }: MetroConfigEnhancerContext
|
|
78
|
+
): MetroConfig => {
|
|
79
|
+
const initializeCore = resolveInitializeCore(projectRoot);
|
|
80
|
+
const existingRunBeforeMainModule =
|
|
81
|
+
metroConfig.serializer?.getModulesRunBeforeMainModule;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
...metroConfig,
|
|
85
|
+
resolver: {
|
|
86
|
+
...metroConfig.resolver,
|
|
87
|
+
platforms: [
|
|
88
|
+
...new Set([
|
|
89
|
+
...(metroConfig.resolver?.platforms ?? ['ios', 'android']),
|
|
90
|
+
'windows',
|
|
91
|
+
'native',
|
|
92
|
+
]),
|
|
93
|
+
],
|
|
94
|
+
resolveRequest: withWindowsPackageRedirect(
|
|
95
|
+
metroConfig.resolver?.resolveRequest ?? undefined
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
serializer: {
|
|
99
|
+
...metroConfig.serializer,
|
|
100
|
+
...(initializeCore
|
|
101
|
+
? {
|
|
102
|
+
getModulesRunBeforeMainModule: (entryPoint: string) => [
|
|
103
|
+
...(existingRunBeforeMainModule?.(entryPoint) ?? []),
|
|
104
|
+
initializeCore,
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export default enhanceMetroConfig;
|
package/src/pwsh.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawn } from '@react-native-harness/tools';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs a PowerShell snippet non-interactively and returns its trimmed stdout.
|
|
5
|
+
* `-NoProfile` keeps it fast and hermetic; `-NonInteractive` makes sure it
|
|
6
|
+
* never blocks on a prompt.
|
|
7
|
+
*/
|
|
8
|
+
export const runPowerShell = async (script: string): Promise<string> => {
|
|
9
|
+
const { stdout } = await spawn(
|
|
10
|
+
'powershell',
|
|
11
|
+
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
12
|
+
{ windowsHide: true }
|
|
13
|
+
);
|
|
14
|
+
return stdout.trim();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the PackageFamilyName of a deployed MSIX package from its identity
|
|
19
|
+
* name (`Package.appxmanifest` `Identity/@Name`). Returns `null` when the app
|
|
20
|
+
* is not deployed.
|
|
21
|
+
*/
|
|
22
|
+
export const getPackageFamilyName = async (
|
|
23
|
+
identityName: string
|
|
24
|
+
): Promise<string | null> => {
|
|
25
|
+
const pfn = await runPowerShell(
|
|
26
|
+
`(Get-AppxPackage -Name '${identityName}' | Select-Object -First 1).PackageFamilyName`
|
|
27
|
+
);
|
|
28
|
+
return pfn === '' ? null : pfn;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Whether at least one process with the given name (no `.exe`) is running. */
|
|
32
|
+
export const isProcessRunning = async (
|
|
33
|
+
processName: string
|
|
34
|
+
): Promise<boolean> => {
|
|
35
|
+
try {
|
|
36
|
+
const count = await runPowerShell(
|
|
37
|
+
`(Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Measure-Object).Count`
|
|
38
|
+
);
|
|
39
|
+
return Number(count) > 0;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Launches a deployed MSIX app by its AUMID
|
|
47
|
+
* (`<PackageFamilyName>!<Application Id>`).
|
|
48
|
+
*
|
|
49
|
+
* `explorer.exe shell:AppsFolder\<aumid>` is the reliable activation path, but
|
|
50
|
+
* `explorer.exe` almost always exits non-zero even on success, so its failure
|
|
51
|
+
* is swallowed — the caller confirms the app came up by polling for its
|
|
52
|
+
* process.
|
|
53
|
+
*/
|
|
54
|
+
export const launchAppByAumid = async (aumid: string): Promise<void> => {
|
|
55
|
+
try {
|
|
56
|
+
await spawn('explorer.exe', [`shell:AppsFolder\\${aumid}`], {
|
|
57
|
+
windowsHide: true,
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
// Expected: explorer.exe reports a non-zero exit even on success.
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Force-terminates every process with the given name. Safe to call when none exist. */
|
|
65
|
+
export const stopProcess = async (processName: string): Promise<void> => {
|
|
66
|
+
try {
|
|
67
|
+
await runPowerShell(
|
|
68
|
+
`Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Stop-Process -Force`
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
// Nothing to stop, or it exited between the query and the kill.
|
|
72
|
+
}
|
|
73
|
+
};
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppSessionEmitter,
|
|
3
|
+
type AppSession,
|
|
4
|
+
type AppSessionState,
|
|
5
|
+
AppNotInstalledError,
|
|
6
|
+
type HarnessPlatformRunnerFactory,
|
|
7
|
+
} from '@react-native-harness/platforms';
|
|
8
|
+
import type { Config as HarnessConfig } from '@react-native-harness/config';
|
|
9
|
+
import { logger } from '@react-native-harness/tools';
|
|
10
|
+
import {
|
|
11
|
+
WindowsPlatformConfigSchema,
|
|
12
|
+
type WindowsPlatformConfigInput,
|
|
13
|
+
} from './config.js';
|
|
14
|
+
import {
|
|
15
|
+
getPackageFamilyName,
|
|
16
|
+
isProcessRunning,
|
|
17
|
+
launchAppByAumid,
|
|
18
|
+
stopProcess,
|
|
19
|
+
} from './pwsh.js';
|
|
20
|
+
|
|
21
|
+
const log = logger.child('platform-windows');
|
|
22
|
+
|
|
23
|
+
const APP_EXIT_POLL_INTERVAL_MS = 1000;
|
|
24
|
+
const APP_START_POLL_INTERVAL_MS = 400;
|
|
25
|
+
const APP_START_POLL_ATTEMPTS = 15;
|
|
26
|
+
|
|
27
|
+
const delay = (ms: number, signal: AbortSignal) =>
|
|
28
|
+
new Promise<void>((resolve, reject) => {
|
|
29
|
+
if (signal.aborted) {
|
|
30
|
+
reject(signal.reason);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
signal.removeEventListener('abort', onAbort);
|
|
35
|
+
resolve();
|
|
36
|
+
}, ms);
|
|
37
|
+
const onAbort = () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
reject(signal.reason);
|
|
40
|
+
};
|
|
41
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const getWindowsRunner: HarnessPlatformRunnerFactory<
|
|
45
|
+
WindowsPlatformConfigInput,
|
|
46
|
+
HarnessConfig
|
|
47
|
+
> = async (config, _harnessConfig, init) => {
|
|
48
|
+
void _harnessConfig;
|
|
49
|
+
|
|
50
|
+
const parsedConfig = WindowsPlatformConfigSchema.parse(config);
|
|
51
|
+
const { packageName, appId } = parsedConfig;
|
|
52
|
+
const processName = parsedConfig.processName ?? packageName;
|
|
53
|
+
|
|
54
|
+
const packageFamilyName = await getPackageFamilyName(packageName);
|
|
55
|
+
|
|
56
|
+
if (packageFamilyName == null) {
|
|
57
|
+
throw new AppNotInstalledError(packageName, 'this machine');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const aumid = `${packageFamilyName}!${appId}`;
|
|
61
|
+
log.debug('resolved AUMID %s for package %s', aumid, packageName);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
createAppSession: async (): Promise<AppSession> => {
|
|
65
|
+
// Clean slate: never attach to an instance left over from a previous run.
|
|
66
|
+
await stopProcess(processName);
|
|
67
|
+
await launchAppByAumid(aumid);
|
|
68
|
+
|
|
69
|
+
// `explorer.exe` returns before the app is up (and lies about its exit
|
|
70
|
+
// code), so confirm the process actually started. `init.signal` cancels
|
|
71
|
+
// this finite readiness wait; it is not a disposal signal.
|
|
72
|
+
let started = false;
|
|
73
|
+
for (let attempt = 0; attempt < APP_START_POLL_ATTEMPTS; attempt += 1) {
|
|
74
|
+
if (await isProcessRunning(processName)) {
|
|
75
|
+
started = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
await delay(APP_START_POLL_INTERVAL_MS, init.signal);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!started) {
|
|
82
|
+
await stopProcess(processName);
|
|
83
|
+
throw new Error(
|
|
84
|
+
`The Windows app '${processName}' did not start after launching ${aumid}. ` +
|
|
85
|
+
`Deploy it first, e.g. \`npx react-native run-windows --arch x64 --no-launch\`.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const emitter = createAppSessionEmitter();
|
|
90
|
+
let state: AppSessionState = { status: 'running' };
|
|
91
|
+
let disposed = false;
|
|
92
|
+
let stopPolling = false;
|
|
93
|
+
let pollDelayTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
94
|
+
let resolvePollDelay: (() => void) | null = null;
|
|
95
|
+
|
|
96
|
+
// Unlike a raced delay() loser (which is fine to just discard), this
|
|
97
|
+
// wait is directly `await`ed by pollTask with nothing else racing it,
|
|
98
|
+
// so cancelling it must also resolve the promise immediately —
|
|
99
|
+
// otherwise dispose() blocks on `await pollTask` for up to
|
|
100
|
+
// APP_EXIT_POLL_INTERVAL_MS instead of returning right away.
|
|
101
|
+
const waitForNextPoll = () =>
|
|
102
|
+
new Promise<void>((resolve) => {
|
|
103
|
+
resolvePollDelay = () => {
|
|
104
|
+
resolvePollDelay = null;
|
|
105
|
+
pollDelayTimeout = null;
|
|
106
|
+
resolve();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
pollDelayTimeout = setTimeout(() => {
|
|
110
|
+
resolvePollDelay?.();
|
|
111
|
+
}, APP_EXIT_POLL_INTERVAL_MS);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const cancelPendingPollDelay = () => {
|
|
115
|
+
if (pollDelayTimeout) {
|
|
116
|
+
clearTimeout(pollDelayTimeout);
|
|
117
|
+
pollDelayTimeout = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
resolvePollDelay?.();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const pollTask = (async () => {
|
|
124
|
+
while (!stopPolling) {
|
|
125
|
+
if (!(await isProcessRunning(processName))) {
|
|
126
|
+
if (!disposed && state.status === 'running') {
|
|
127
|
+
state = {
|
|
128
|
+
status: 'exited',
|
|
129
|
+
occurredAt: Date.now(),
|
|
130
|
+
reason: 'process-gone',
|
|
131
|
+
};
|
|
132
|
+
emitter.emit({ type: 'app_exited' });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (stopPolling) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await waitForNextPoll();
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
|
|
145
|
+
const session: AppSession = {
|
|
146
|
+
dispose: async () => {
|
|
147
|
+
if (disposed) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
disposed = true;
|
|
152
|
+
stopPolling = true;
|
|
153
|
+
cancelPendingPollDelay();
|
|
154
|
+
state = { status: 'disposed', occurredAt: Date.now() };
|
|
155
|
+
emitter.clear();
|
|
156
|
+
await stopProcess(processName);
|
|
157
|
+
await pollTask;
|
|
158
|
+
},
|
|
159
|
+
getState: async () => state,
|
|
160
|
+
getLogs: () => [],
|
|
161
|
+
addListener: emitter.addListener,
|
|
162
|
+
removeListener: emitter.removeListener,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return session;
|
|
166
|
+
},
|
|
167
|
+
dispose: async () => {
|
|
168
|
+
await stopProcess(processName);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export default getWindowsRunner;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"files": [],
|
|
4
|
+
"include": [],
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "../tools"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "../platforms"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"path": "../config"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "../bundler-metro"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "./tsconfig.lib.json"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"baseUrl": ".",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
|
|
8
|
+
"emitDeclarationOnly": false,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"],
|
|
13
|
+
"references": [
|
|
14
|
+
{
|
|
15
|
+
"path": "../tools/tsconfig.lib.json"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "../platforms/tsconfig.lib.json"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "../config/tsconfig.lib.json"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"path": "../bundler-metro/tsconfig.lib.json"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types='vitest' />
|
|
2
|
+
import { defineConfig } from 'vite';
|
|
3
|
+
|
|
4
|
+
export default defineConfig(() => ({
|
|
5
|
+
root: __dirname,
|
|
6
|
+
cacheDir: '../../node_modules/.vite/packages/platform-windows',
|
|
7
|
+
test: {
|
|
8
|
+
watch: false,
|
|
9
|
+
globals: true,
|
|
10
|
+
environment: 'node',
|
|
11
|
+
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
|
12
|
+
reporters: ['default'],
|
|
13
|
+
coverage: {
|
|
14
|
+
reportsDirectory: './test-output/vitest/coverage',
|
|
15
|
+
provider: 'v8' as const,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
}));
|